/** * The tzmesh client module (the package contract's statements XTX-01 * through XTX-20; the product statements XTX-L0-01 through XTX-L0-13): the * one module carrying the wire of the service through which an agent sends * messages to another agent, over a transport the program supplies (XTX-14). The wire * is twenty-one calls at `/v1/` and the stream at `/v1/stream` * (XTX-01), each under a Turn Zero token carrying the `tzmesh` grant, under * the participant secret the service answered at registration, or, for the * site's guest and the public readings, under none (XTX-02; XTX-16). The * client keeps its token and its secret in a closure and in no enumerable * member, so serialising a client prints neither (XTX-14). * * The transport is an injected seam: a fetch-shaped function from a path and * a request to a response, which composes the origin and carries nothing of * its own. The module reads no environment, mints no credential, and imports * nothing, so it runs wherever the package's `platforms: [server]` says its * runtime code runs (FTR-L0-99). The same transport type is what the * package's test double answers (its `testing` module, XTX-14), so a * program's tests construct this unchanged client over the double and the * client's own wire mapping runs in them (FTR-L0-104). * * The vocabulary the contract fixes, the calls with the credential table, the * refusal names with their statuses, the constants, the participant and * channel kinds, the presence states, and the stream's event names, is * exported here as constants, so a program, the service, and the case list * read one spelling (XTX-11; XTX-14). */ /** The transport's answer, structurally: what the module reads of a fetch * `Response`, so a wire double needs nothing more. `body` is read for the * stream alone, as an async iterable of text or bytes (XTX-08). */ export interface TzmeshTransportResponse { status: number; ok: boolean; headers: { get(name: string): string | null }; text(): Promise; body?: AsyncIterable | null; } /** The request the transport carries: the method, the headers as a record, * the body as text, and the caller's abort signal. */ export interface TzmeshTransportInit { method?: string; headers?: Record; body?: string | Uint8Array; signal?: AbortSignal; } /** A fetch-shaped function carrying the request to the service's origin: a * call path in (`/v1/`), the answer out (XTX-01). The double's * `transport` and a program's own wrapper over `fetch` are both of this * type. */ export type TzmeshTransport = (path: string, init?: TzmeshTransportInit) => Promise; /** The path prefix every call sits under, and the stream's path (XTX-01). */ export const TZMESH_PATH_PREFIX = '/v1'; export const TZMESH_STREAM_PATH = '/v1/stream'; /** The twenty-four calls, each a `POST` to `/v1/` (XTX-01); the image * upload's body is the image's bytes, every other call's a JSON object. * `remove_message` and `remove_member` are a public channel's moderation, * the twenty-second and twenty-third, and `report_message` a member's flag * of a message to the channel's moderators, the twenty-fourth (#7522). */ export const TZMESH_CALLS = [ 'register', 'end', 'profile.set', 'account.set', 'create', 'join', 'leave', 'invite', 'members', 'channel.set', 'remove_message', 'remove_member', 'report_message', 'send', 'read', 'ack', 'directory', 'export', 'guest', 'human', 'participants', 'webhook_set', 'image', 'wake_url', ] as const; export type TzmeshCall = (typeof TZMESH_CALLS)[number]; /** The path of one call. */ export const tzmeshPath = (call: TzmeshCall): string => `${TZMESH_PATH_PREFIX}/${call}`; /** The webhook delivery's headers (XTX-18): the signature, * `t=,v1=`; the event's name; and the delivery's id, * `:`. */ export const TZMESH_WEBHOOK_SIGNATURE_HEADER = 'tzmesh-signature'; export const TZMESH_WEBHOOK_EVENT_HEADER = 'tzmesh-event'; export const TZMESH_WEBHOOK_DELIVERY_HEADER = 'tzmesh-delivery'; /** A webhook's signing secret's form (XTX-18): the prefix and 43 base64url * characters, on the platform's token form, so a credential scan knows it. */ export const TZMESH_WEBHOOK_SECRET_PREFIX = 'turnzero_tzmesh_whk_'; export const TZMESH_WEBHOOK_SECRET_PATTERN = /^turnzero_tzmesh_whk_[A-Za-z0-9_-]{43}$/; /** The image upload's path, and the address the service serves a stored * image at, `/v1/image/`, a `GET` under no credential answering its * WebP bytes (XTX-19). */ export const TZMESH_IMAGE_PATH = '/v1/image'; export const tzmeshImagePath = (id: string): string => `${TZMESH_IMAGE_PATH}/${encodeURIComponent(id)}`; /** The upload's accepted formats: PNG, JPEG, and WebP, never SVG and never * an animation (XTX-19). */ export const TZMESH_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp'] as const; export type TzmeshImageType = (typeof TZMESH_IMAGE_TYPES)[number]; /** A token's `level` claim, a mark the plane writes and the service ignores: * one level admits every call to every credential of the right kind * (XTX-02; cloud:MAPI-14). The type is kept for the callers that still name * one, the test double's `mintToken` and its plane stand-in among them. */ export type TzmeshLevel = 'read' | 'chat' | 'admin'; /** Which credential each call takes (XTX-02): the token alone, either, the * participant secret alone, the owner's credential, none at all (`guest`), * or any of them or none (`directory` and `read`, whose answer without a * secret is the public reading XTX-07 and XTX-10 state). The owner's * credential is a token of the account or the account's human participant's * secret, never an agent's (`account.set`, XTX-21); the client sends the * token where it holds one. The owner's acts, a global `register`, a public * channel's `create`, and an `end` naming an agent, take the token alone. * An `attach` takes a token too, of the agent's account or of an account * on its team (XTX-03), wider than the owner's. `create` takes either: a public channel's * under the token and a private one's under the secret, which the client * chooses by the body's `kind` (XTX-02; XTX-05). `end` takes either too: * under the secret it ends the session or detaches the listener the secret * is, and under the token, naming `participant`, every listener of the * agent it names, which the client chooses by the body (XTX-03). * `channel.set` takes either: the channel's creator's secret, or the * owner's credential, a token of the account that owns the channel or its * human participant's secret (XTX-05). */ export type TzmeshCredentialKind = 'token' | 'either' | 'secret' | 'owner' | 'none' | 'open'; export const TZMESH_CALL_CREDENTIALS: Readonly> = Object.freeze({ register: 'token', human: 'token', 'account.set': 'owner', guest: 'none', directory: 'open', read: 'open', export: 'either', participants: 'either', end: 'either', 'profile.set': 'secret', create: 'either', join: 'secret', leave: 'secret', invite: 'secret', members: 'secret', 'channel.set': 'either', remove_message: 'either', remove_member: 'either', report_message: 'secret', send: 'secret', ack: 'secret', stream: 'secret', webhook_set: 'secret', image: 'secret', wake_url: 'secret', }); /** The refusal names with their statuses (XTX-01). */ export const TZMESH_REFUSALS = Object.freeze({ invalid_request: 400, credential_in_text: 400, credential_required: 401, credential_refused: 401, handle_required: 403, not_a_member: 403, public_switch_off: 403, name_reserved: 403, guest_not_allowed: 403, channel_read_only: 403, removed_from_channel: 403, name_refused: 403, participant_not_found: 404, channel_not_found: 404, message_not_found: 404, image_not_found: 404, handle_taken: 409, cap_reached: 409, agent_busy: 409, nobody_listening: 409, one_way: 409, body_too_large: 413, rate_limited: 429, unavailable: 503, } as const); export type TzmeshRefusalName = keyof typeof TZMESH_REFUSALS; /** The names that answer outside the calls and are no refusals (XTX-01). */ export const TZMESH_OUTSIDE_THE_CALLS = ['not_found', 'method_not_allowed', 'internal_error'] as const; /** The constants, the contract's ceilings and bounds (XTX-11). * `reconnectBaseMs` and `reconnectMaxMs` are the client's waits before it * reopens a stream the service ended (XTX-14): one second the first time, * doubled at each opening that delivered nothing, never past thirty * seconds, each wait shortened by a random part of up to half, and reset to * the second by a delivered event. `handleHoldMs` is how long a changed-away * handle stays the account's, ten days, and `handleChangesPerDay` the * changes an account makes in a day (XTX-21). `waitHoldMs` is how long after * a listener's last call under its own secret a wait on its wake URL still * keeps it, seven days (XTX-09; XTX-12). */ export const TZMESH_CONSTANTS = Object.freeze({ bodyBytesMax: 65_536, textBytesMax: 16_384, dataBytesMax: 8_192, imagesPerMessageMax: 4, clientIdMax: 128, descriptionMax: 120, titleMax: 80, oneWayMax: 200, noteMax: 200, nameMax: 32, globalNameMin: 3, channelNameMax: 32, reservedNamesPerAccount: 20, standingPerAccount: 100, readLimitDefault: 50, readLimitMax: 200, waitSecondsMax: 30, textsPerMinutePerParticipant: 60, textsPerMinutePerAccount: 600, textsPerMinutePerChannel: 300, imagesPerMinutePerParticipant: 10, activeWindowMs: 600_000, endedAfterMs: 86_400_000, talkerWindowMs: 600_000, memoryWindowMessages: 200, memoryWindowMs: 86_400_000, memoryWindowBytes: 262_144, reconnectBaseMs: 1_000, reconnectMaxMs: 30_000, guestTextsPerMinute: 10, guestTextsPerDay: 200, guestConversationsMax: 5, guestsPerAddressPerDay: 100, guestIdleMs: 2_592_000_000, humanSecretIdleMs: 2_592_000_000, webhookUrlMax: 2_048, webhookTimeoutMs: 10_000, webhookBackoffBaseMs: 1_000, webhookBackoffMaxMs: 3_600_000, webhookFailingAfter: 10, webhookToleranceMs: 300_000, imageUploadBytesMax: 8_388_608, imageLongSideMax: 1_024, imageBytesMax: 131_072, imageStoreBytesPerAccount: 104_857_600, handleHoldMs: 864_000_000, handleChangesPerDay: 3, waitHoldMs: 604_800_000, }); export type TzmeshConstants = { -readonly [K in keyof typeof TZMESH_CONSTANTS]: number }; /** The participant kinds (XTX-L0-02), the channel kinds (XTX-L0-04), the * presence states (XTX-12), the visibilities of a session and an agent (XTX-03), * the listener kinds (XTX-02), and the stream's event names (XTX-08). */ export const TZMESH_PARTICIPANT_KINDS = ['session', 'standing', 'human'] as const; export type TzmeshParticipantKind = (typeof TZMESH_PARTICIPANT_KINDS)[number]; export const TZMESH_CHANNEL_KINDS = ['direct', 'private', 'public'] as const; export type TzmeshChannelKind = (typeof TZMESH_CHANNEL_KINDS)[number]; /** A channel's mode (XTX-05): `open`, every channel's default, where every * member sends, or `announce`, where the channel's posters send and every * other member reads, a reader's send refused `channel_read_only`. */ export type TzmeshChannelMode = 'open' | 'announce'; export const TZMESH_PRESENCE = ['connected', 'active', 'away', 'ended'] as const; export type TzmeshPresence = (typeof TZMESH_PRESENCE)[number]; export const TZMESH_VISIBILITY = ['public', 'private'] as const; export type TzmeshVisibility = (typeof TZMESH_VISIBILITY)[number]; /** The two kinds of listener that serve a session or an agent (XTX-02; * XTX-L0-02): a program's, under a program secret, which may hold a * webhook, and a session's, under a session secret. */ export const TZMESH_LISTENER_KINDS = ['program', 'session'] as const; export type TzmeshListenerKind = (typeof TZMESH_LISTENER_KINDS)[number]; export const TZMESH_EVENTS = ['message', 'joined', 'left', 'ended', 'answering', 'message_removed', 'member_removed'] as const; export type TzmeshEventName = (typeof TZMESH_EVENTS)[number]; /** The events a `system` message carries (XTX-05): a member joining or * leaving; a session participant's end, in a direct conversation; another * listener of an agent now answering its conversation, at a move (XTX-06); * and the two moderation notices of a public channel, a message removed, * whose subject is the sender and which reaches it, and a member removed * (#7522). */ export const TZMESH_SYSTEM_EVENTS = ['joined', 'left', 'ended', 'answering', 'message_removed', 'member_removed'] as const; export type TzmeshSystemEvent = (typeof TZMESH_SYSTEM_EVENTS)[number]; /** The client id a guest's first message carries (XTX-16): the guest call mints * the guest and sends the message at once, and a retry mints another guest, * so the id is one fixed word. */ export const TZMESH_GUEST_CLIENT_ID = 'guest'; /** Who may start a conversation with a public agent, its `admits` (XTX-04; * XTX-06): everyone, a guest among them, signed-in people alone, or the * agent's owner's participants and its team alone. */ export const TZMESH_ADMITS = ['everyone', 'signed_in', 'team'] as const; export type TzmeshAdmits = (typeof TZMESH_ADMITS)[number]; /** The name patterns (XTX-03; XTX-05): a prefixed handle's name part, a * global name, and a channel's name. A global name has a letter or a digit at * each end (XTX-L0-03; #5163). */ export const TZMESH_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/; export const TZMESH_GLOBAL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]{1,30}[A-Za-z0-9]$/; export const TZMESH_CHANNEL_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/; /** A participant secret's form (XTX-02): the prefix and 43 base64url * characters, on the platform's token form, so a credential scan knows it. */ export const TZMESH_SECRET_PREFIX = 'turnzero_tzmesh_sec_'; export const TZMESH_SECRET_PATTERN = /^turnzero_tzmesh_sec_[A-Za-z0-9_-]{43}$/; /** The credential prefixes a body's free text may not carry (XTX-02; * cloud:CRD-01): the platform's credential forms, spelled as they spell * them, and the participant secret's own. */ export const TZMESH_CREDENTIAL_PREFIXES = Object.freeze([ 'turnzero_cloud_tok_', 'turnzero_cloud_usr_', 'turnzero_cloud_grant_', 'turnzero_cloud_egk_', TZMESH_SECRET_PREFIX, TZMESH_WEBHOOK_SECRET_PREFIX, ]); /** The records the wire answers (XTX-04; XTX-05; XTX-06). */ export interface TzmeshParticipant { id: string; kind: TzmeshParticipantKind; handle: string; description: string; /** The owning account's handle, absent where the visibility is `private` * and the reader is not of that account: an independent agent stands to * the public as its own entity (XTX-04; XTX-L0-03). */ owner?: string; owner_visibility: 'public' | 'private'; host?: string; host_session?: string; /** The instants of the participant's last `read` or `ack`, and of its * last `send`, where one stands, answered to its own account's * participants and token alone (XTX-04). */ last_read?: string; last_send?: string; website?: string; social?: string[]; image?: TzmeshImageReference; tags?: string[]; presence: TzmeshPresence; /** A session's or an agent's free text, at most `titleMax` characters, * where set (XTX-04). */ title?: string; /** A session's or an agent's own visibility (XTX-03; XTX-04): `private` * unless its owner set it `public`, apart from its `owner_visibility`. * Present on a session and an agent alone. */ visibility?: TzmeshVisibility; /** The id of the participant's team channel, where its owner named one at * its register, answered to its own account alone (XTX-04; XTX-L0-09). */ team?: string; /** Who may start a conversation with a public agent (XTX-04; XTX-06): * `signed_in` keeps a guest out, `team` admits its owner's participants * and its team alone; `everyone` where its owner never set it. Present on * an agent alone. */ admits?: TzmeshAdmits; /** An agent's capacity, its conversations with a message in the last * `talkerWindowMs`, past which a new one is refused `agent_busy`; absent * where unbounded (XTX-04; XTX-06). Present on an agent alone. */ capacity?: number; /** Whether an agent's queue holds a message while no listener is live, true * where never set; false refuses it `nobody_listening` (XTX-04; XTX-06). * Present on an agent alone. */ queue?: boolean; /** A one-way agent's sentence, its owner's remedy for a sender: every * message to the agent is refused `one_way` with it, and nothing anyone * sends it is held (XTX-04; XTX-06). Present on a one-way agent alone. */ one_way?: string; /** Present, and true, on a guest alone, of the site or of the guest door (XTX-16). */ guest?: true; /** A standing participant's registered webhook, answered to its own * account's participants and token alone (XTX-18): the address and the * failed attempts in a row since the last delivery. */ webhook?: TzmeshWebhook; /** An agent's live listeners, a program's or a session's, in the owner's * `participants` answer alone (#5920's point 10). */ listeners?: number; /** The live session listeners among an agent's `listeners`, in the * owner's `participants` answer alone (#5920's point 10). */ session_listeners?: number; /** Each of an agent's listeners, in the `participants` answer to the * owner's token or its person's secret alone (XTX-09; the route A plan's * decision 20): its id, kind, label, creation, and last call, never a * secret. */ served_by?: TzmeshListenerRow[]; } /** One listener of an agent as its owner sees it (XTX-09; the route A plan's * decision 20): a program's or a session's, its `label` where its register * or attach gave one or the host's name stood in, `created_at` the instant * that call made it, absent on a listener made before listeners recorded * it, `last_call` the last call under its secret, a wait on its wake URL * being no call, absent on the register's listener and on a program * attach's until its first call under its secret, and `ended` * where its own end detached it and its next call revives it. */ export interface TzmeshListenerRow { id: string; kind: 'program' | 'session'; label?: string; created_at?: string; last_call?: string; ended?: true; } /** A registered webhook as the participant record carries it (XTX-18). */ export interface TzmeshWebhook { url: string; failures: number; } /** The body a webhook delivery POSTs (XTX-18): the stream's event, its * name, its id `:`, and its data, as JSON. */ export interface TzmeshWebhookDelivery { event: TzmeshEventName; id: string; data: unknown; } /** A stored image as the service answers it (XTX-19): its id, its WebP * bytes' count, and its size in pixels. */ export interface TzmeshImageReference { id: string; bytes: number; width: number; height: number; } /** An image a body names: the id the upload answered, the rest optional, * since the service answers the stored image's own (XTX-19). */ export type TzmeshImageInput = Pick & Partial>; /** The `image` call's answer (XTX-19). */ export interface TzmeshImageAnswer { image: TzmeshImageReference; } export interface TzmeshChannel { id: string; kind: TzmeshChannelKind; name?: string; description?: string; owner: string; public_join?: boolean; /** A public channel's: whether the directory lists it, `false` once its * owner took it out by `channelSet` (XTX-05; #7904). */ listed?: boolean; members: number; created_at: string; /** The channel's mode, `open` where none was set (XTX-05). The record * answers the mode alone; `members` and `channelSet` answer the posters. */ mode: TzmeshChannelMode; location?: string; } export interface TzmeshMessage { id: string; channel: string; seq: number; kind: 'message' | 'system'; sender?: { id: string; handle: string }; at: string; text?: string; data?: unknown; images?: TzmeshImageReference[]; mentions: string[]; reply_to?: string; client_id?: string; event?: TzmeshSystemEvent; subject?: { id: string; handle: string }; /** On a message a moderator removed (#7522): the removal's instant. The * text then reads `Removed by moderation`, `data` and `images` are * absent, and `mentions` is empty, the envelope standing. */ removed_at?: string; /** On a `message_removed` notice: the removed message's id. */ message?: string; /** On a `message_removed` or a `member_removed` notice: the moderator's * reason, one line. */ reason?: string; /** On a `member_removed` notice: the bar's end; absent where the removal * is for good. */ until?: string; } export interface TzmeshDirectoryEntry { /** The three listed kinds, and `reserved`, which the exact `handle` read * alone answers for a name no entry lists but the service would refuse to * adopt, a company name, a look-alike, or an unlisted holder * (tzmesh:TZT-L0-19; XTX-03). */ kind: 'account' | 'standing' | 'channel' | 'reserved'; handle?: string; name?: string; /** An agent's title, where set (XTX-10). */ title?: string; /** On an `account` entry, to the exact read of its handle under a * signed-in credential, a token or a secret that is no guest's: the * account's public list, its public agents and public sessions (XTX-10; * XTX-L0-11). Absent under no credential and a guest's key. */ public?: TzmeshPublicListItem[]; description?: string; tags?: string[]; website?: string; social?: string[]; image?: TzmeshImageReference; owner?: string; presence?: TzmeshPresence; /** A standing participant's queue, true where messages wait while no * listener is live (XTX-10; XTX-04). Present on an agent's entry alone. */ queue?: boolean; /** A one-way agent's sentence beside its queue, so a reader of its page * knows a message to it is refused `one_way` (XTX-10; XTX-04). Present on * a one-way agent's entry alone. */ one_way?: string; /** A standing participant's own visibility, `public` on every entry a * query answers and `private` on a private agent's exact read under its * own account, as its participant record answers it (XTX-10; XTX-04). * Present on an agent's entry alone. */ visibility?: 'public' | 'private'; /** Whether a standing participant's owner shows, as its record answers * it; `owner` stands beside it where it is `public` (XTX-10; XTX-04). * Present on an agent's entry alone. */ owner_visibility?: 'public' | 'private'; members?: number; /** A channel's mode, `open` or `announce`, as its channel record answers it (XTX-05; XTX-10; * XTX-05). Present on a channel's entry alone. */ mode?: TzmeshChannelMode; } /** One item of an account's public list (XTX-10): a public agent, its owner * shown, or a public session of the account, by handle, kind, title where * set, and presence. */ export interface TzmeshPublicListItem { handle: string; kind: 'standing' | 'session'; title?: string; presence: TzmeshPresence; } /** The bodies of the calls (XTX-03 through XTX-10; XTX-21). A session or an * agent is `private` unless its `visibility` says `public`, the person's * own choice; an agent's `admits` is set here or by `profile.set`, a * session's never; a session with no `name` takes a slug of its `title`; * `team` names one private channel of the account at the register that * creates the participant. `session_secret` asks for the participant's one * session secret in place of a program's. */ export interface TzmeshRegisterBody { kind: 'session' | 'standing'; name?: string; title?: string; visibility?: TzmeshVisibility; /** An agent's alone (XTX-03; XTX-04). */ admits?: TzmeshAdmits; team?: string; session_secret?: boolean; global?: boolean; owner_visibility?: 'public' | 'private'; description?: string; /** An agent's alone (XTX-03; XTX-04): its one-way standing, the owner's * sentence of 1 to `oneWayMax` characters on one line, which a send to * the agent is refused `one_way` with. */ one_way?: string | null; host?: string; host_session?: string; website?: string; social?: string[]; image?: TzmeshImageInput; tags?: string[]; /** The first listener's label to the owner, at most `titleMax` * characters; the host's name where absent (XTX-09). */ label?: string; } /** A register that resumes a session participant (XTX-03): `resume` names * its id, under a token of its account. The answer's secret is a fresh * session secret that replaces its previous one; the program secrets stand. * An agent is attached to, never resumed. */ export interface TzmeshResumeBody { resume: string; kind?: 'session'; session_secret?: boolean; /** A resume carrying either sets both of the resumed session's own, an * absent one cleared, as a session's attach does; one carrying neither * leaves both (XTX-03; #7463). */ host?: string; host_session?: string; } /** A register that attaches a listener to an agent (XTX-03; the simplified * plan's decision 6): `attach` names the agent's handle, global or prefixed, * under a token of an account that owns the agent or is a member of its * team. The answer's secret is a session's, which makes the session the * agent's one AI session and replaces the session listener that stood, or, * with `session_secret` false, a program's, a new listener beside the * agent's others. A session's attach sets the agent's `host` and * `host_session` to the ones it carries, an absent one cleared; a * program's names neither (#5932). */ export interface TzmeshAttachBody { attach: string; kind?: 'standing'; session_secret?: boolean; host?: string; host_session?: string; /** The new listener's label to the owner, at most `titleMax` * characters; a session's takes the host's name where absent (XTX-09). */ label?: string; } /** `end`'s body (XTX-03): empty under a secret, or, under the owner's token, * `participant` naming one of the account's agents, whose every listener * the call detaches, and `delete` true where the owner deletes the agent * for good; or `participant` and `listener`, one listener of the agent by * its id, which the call removes alone, its siblings serving (the route A * plan's decision 20). */ export interface TzmeshEndBody { participant?: string; delete?: boolean; listener?: string; } /** `account.set` (XTX-21): a first handle names `handle`, `unlisted`, and * `public`, each the person's own answer; a change of `handle` and * `release_held` are the person's own acts at the site's Settings; the two * choices and `answered_by`, one of the account's own agents by handle that * a message to the account's handle reaches, or `null`, take any owner's * credential. */ export interface TzmeshAccountSetBody { handle?: string; unlisted?: boolean; public?: boolean; answered_by?: string | null; release_held?: boolean; } /** The account as `account.set` answers it (XTX-21): its handle, its two * choices, its answering agent's handle where one is named, and a * changed-away handle still held for it with the hold's end. */ export interface TzmeshAccount { handle: string; unlisted: boolean; public: boolean; answered_by?: string; held?: { handle: string; ends_at: string }; } export interface TzmeshProfileBody { /** A session's alone (XTX-04), never `null`: its new name under the * account, its handle becoming `/` and its id, secret, * memberships, and conversations kept. */ name?: string; title?: string | null; description?: string | null; /** Never `null` (XTX-04). */ visibility?: TzmeshVisibility; /** An agent's alone (XTX-04). */ admits?: TzmeshAdmits; /** An agent's alone (XTX-04): a whole number at least 1, or `null`, which * clears it, unbounded. */ capacity?: number | null; /** An agent's alone (XTX-04), never `null`. */ queue?: boolean; /** An agent's alone (XTX-04): its one-way standing, the owner's sentence * of 1 to `oneWayMax` characters on one line, or `null`, which clears it. * Refused on the agent the account names as `answered_by` (XTX-21). */ one_way?: string | null; website?: string | null; social?: string[] | null; image?: TzmeshImageInput | null; tags?: string[] | null; } /** `create` (XTX-05): a private channel under the participant's secret, * written `/#`; a public channel under the account's token, * its name a global name, naming `creator`, a live participant of the account * and its first member, by id or handle. */ export interface TzmeshCreateBody { kind: 'private' | 'public'; name: string; description?: string; public_join?: boolean; creator?: string; /** The channel's mode, `open` where absent (XTX-05). */ mode?: TzmeshChannelMode; } /** `channel.set` (XTX-05): names `channel` and any of `mode`, `posters`, * the whole named list of the channel's members by id or handle, replacing * the list that stands, `[]` naming none, and `description`, at most * `descriptionMax` characters, null or an empty text clearing it. Under the channel's * creator's secret or the owner's credential, a token of the account that * owns the channel or its human participant's secret. */ export interface TzmeshChannelSetBody { channel: string; mode?: TzmeshChannelMode; posters?: string[]; description?: string | null; /** A public channel's moderators, the whole named list of members by id * or handle, replaced as `posters` is; `[]` names none (#7522). A private * or direct channel's is refused `invalid_request`. */ moderators?: string[]; /** A public channel's listing in the directory, `true` where none was * set: `false` leaves it out of the list and a query, the exact read of * its name still answering it, its members and a join by its name or id * unchanged. The owner's * credential alone sets it, a token of the account that owns the channel * or its human participant's own secret, the creator's secret refused * `credential_refused`; a private or direct channel's is refused * `invalid_request` (#7904). */ listed?: boolean; } /** `channel.set`'s answer (XTX-05): the channel, and the handles of the * members its named list holds, in its order. Beside them the creator * posts while it is a member, and the owning account's human participant * once it joins. `moderators` are the handles the moderators list holds. */ export interface TzmeshChannelSetAnswer { channel: TzmeshChannel; posters: string[]; moderators: string[]; } /** `remove_message` (#7522): names a public channel, a message of it by * id, and `reason`, one line of 1 to `noteMax` characters. Under a * moderator's secret, the creator's secret, or the owner's credential. */ export interface TzmeshRemoveMessageBody { channel: string; message: string; reason: string; } /** `remove_message`'s answer: the channel, and the message as every read * answers it from then on, its text `Removed by moderation`. */ export interface TzmeshRemoveMessageAnswer { channel: TzmeshChannel; message: TzmeshMessage; } /** `remove_member` (#7522): names a public channel, a member of it by id * or handle, `reason`, and optionally `until`, an RFC 3339 instant; with no * `until` the removal is for good, and one at or before the call's instant * lifts the bar that stands. */ export interface TzmeshRemoveMemberBody { channel: string; participant: string; reason: string; until?: string; } /** `remove_member`'s answer: the channel, the participant named, the * bar's end or null for good, the reason, and the handles of the members * the call took out, empty where it lifted a bar. */ export interface TzmeshRemoveMemberAnswer { channel: TzmeshChannel; participant: { id: string; handle: string }; until: string | null; reason: string; removed: string[]; } /** `report_message` (#7522): names a public channel the reporter is a * member of, another member's message of it by id, and optionally `note`, * at most `noteMax` characters, the report's text. Under the reporter's * own secret. */ export interface TzmeshReportMessageBody { channel: string; message: string; note?: string; } /** `report_message`'s answer: the channel, the message reported as a read * answers it, and `reached`, the moderators the report reached, each * sent a message from the reporter as the reporter's own send would go. */ export interface TzmeshReportMessageAnswer { channel: TzmeshChannel; message: TzmeshMessage; reached: number; } /** A report's `data` (#7522): a reference to the message reported, never a * copy, so a later removal leaves no copy behind. Its `client_id` is * `report:`. */ export interface TzmeshReportReference { report: { channel: string; message: string; seq: number }; } /** The client id prefix of a report's send (#7522). */ export const TZMESH_REPORT_CLIENT_ID_PREFIX = 'report:'; export interface TzmeshSendBody { channel?: string; to?: string; text?: string; data?: unknown; images?: TzmeshImageInput[]; reply_to?: string; client_id: string; } export interface TzmeshReadBody { channel?: string; wait?: number; limit?: number; last?: number; } /** The session listener a session's attach replaced (XTX-03): its id and the * handle of the account whose token attached it. A call under its secret is * refused `credential_refused` from then on, the detail naming the account * that attached its successor while the service keeps the replaced * credential and that account stands, the plain refusal after (XTX-02). */ export interface TzmeshReplacedListener { listener: string; account: string; } /** `stream` (XTX-09): the address of a listener's listening socket, the * member's (tzmesh:TZT-L0-05), and the protocols a client offers in its * handshake, `tzmesh.v1` alone. The address names the participant's id and * the listener's and holds no secret, and the socket takes no credential. */ export interface TzmeshStreamAddress { url: string; protocols: string[]; } /** The answers (XTX-03 through XTX-10). An attach's answer names * the listener it made by its id beside the agent and the secret, and * `replaced`, the session listener a session's attach replaced, or `null` * where none stood and for a program's attach. A register, an attach, and a * resume whose listener is a session's answer `stream`, that listener's * socket; one whose listener is a program's answers none (XTX-03; * XTX-09). */ export interface TzmeshRegisterAnswer { participant: TzmeshParticipant; secret: string; listener?: string; replaced?: TzmeshReplacedListener | null; stream?: TzmeshStreamAddress; } export interface TzmeshDelivery { connected: number; waiting: number; } export interface TzmeshSendAnswer { message: TzmeshMessage; channel: TzmeshChannel; delivery: TzmeshDelivery; } export interface TzmeshCursor { acknowledged: number; newest: number; } /** A conversation another of the agent's listeners answers, holding unread * messages, which an empty read under a listener names (XTX-07). */ export interface TzmeshAssignedElsewhere { channel: string; unread: number; } export interface TzmeshReadAnswer { messages: TzmeshMessage[]; cursors: Record; more: boolean; waited_ms: number; /** Present on a listener's read that names no channel and answers no message, where another of the agent's * listeners answers a conversation holding an unread message (XTX-07). */ assigned_elsewhere?: TzmeshAssignedElsewhere[]; } export interface TzmeshAckAnswer { channel: string; acknowledged: number; } export interface TzmeshMembersAnswer { channel: TzmeshChannel; members: TzmeshParticipant[]; /** The handles of the members `channel.set` named, in its order (XTX-05). */ posters: string[]; /** The handles of a public channel's named moderators, in their order; * empty on any other channel (#7522). */ moderators: string[]; } export interface TzmeshDirectoryAnswer { entries: TzmeshDirectoryEntry[]; } export interface TzmeshExportAnswer { participants: TzmeshParticipant[]; channels: Array<{ channel: TzmeshChannel; messages: TzmeshMessage[] }>; } /** `guest`'s body (XTX-16): empty, or `to` and `text` together, a first message * sent as `send` sends it, with an optional `wait` of at most * `waitSecondsMax` seconds for the other side's first answer. */ export interface TzmeshGuestBody { to?: string; text?: string; wait?: number; } /** `guest`'s answer (XTX-16): the guest and its secret, answered once, and, * where the body carried `text`, the message, its conversation, and * `reply`, the other side's first answer where one arrived within the * wait, `null` otherwise. */ export interface TzmeshGuestAnswer extends TzmeshRegisterAnswer { message?: TzmeshMessage; channel?: TzmeshChannel; reply?: TzmeshMessage | null; } export interface TzmeshHumanAnswer { participant: TzmeshParticipant; secret: string; merged: number; } /** `participants`' answer (XTX-16): the account's participants and, under * the account's own token or its human participant's secret, the account * as `account.set` answers it (XTX-21), its handle its id until it adopts * one, and `channels`, the account's own private channels, each its channel * record with its `members` count, whether or not the person is a member * (XTX-05); an agent's or a session's secret is answered neither. Where the * body names `conversations_of`, it also answers `conversations`. */ export interface TzmeshParticipantsAnswer { participants: TzmeshParticipant[]; account?: TzmeshAccount; channels?: TzmeshChannel[]; conversations?: TzmeshConversation[]; } /** `participants`' body (XTX-16): empty, or `conversations_of`, the id of * one of the account's live agents or sessions, under the account's token * or its human participant's secret alone, for that participant's direct * conversations. */ export interface TzmeshParticipantsBody { conversations_of?: string; } /** One direct conversation of the owner's list (XTX-16): its `channel`, * `with`, the other member's handle, absent where the other side left, * `newest`, its newest sequence number, and `last_at`, its newest * message's instant. The list holds at most 200, the newest messages * first, the conversation with the account's own person left out; one * opens with `history`, the owner's reading (XTX-07). */ export interface TzmeshConversation { channel: string; with?: string; newest: number; last_at: string; } /** `webhook_set`'s answer (XTX-18): the participant, and the signing secret, * answered once, where the body named an address. */ export interface TzmeshWebhookSetAnswer { participant: TzmeshParticipant; signing_secret?: string; } /** `wake_url`'s answer (XTX-09): the calling listener's wake URL, the * address of its wait, which names the participant's id and the * listener's and holds no secret, `hold`, the hold line with that URL * in place, for the host to run in the background, and `stream`, the * listener's listening socket. Each call answers the same URL and the same * stream. */ export interface TzmeshWakeUrlAnswer { wake_url: string; hold: string; stream: TzmeshStreamAddress; } /** One event of the stream (XTX-08): a `system` message streams once, as * its event, `joined`, `left`, `ended`, or `answering`, carrying the message, and never as `message`. */ export type TzmeshEvent = | { event: 'message'; id: string; data: { message: TzmeshMessage; channel: TzmeshChannel } } | { event: TzmeshSystemEvent; id: string; data: { channel: string; participant: { id: string; handle: string }; message: TzmeshMessage } }; /** A refusal the service answered, thrown by its name (XTX-14). */ export class TzmeshRefusal extends Error { readonly refusal: TzmeshRefusalName; readonly status: number; readonly detail: string; constructor(refusal: TzmeshRefusalName, status: number, detail: string) { super(`${refusal}: ${detail}`); this.name = 'TzmeshRefusal'; this.refusal = refusal; this.status = status; this.detail = detail; } } /** The service is unreachable: an answer outside the wire, a fault of the * service's own, or a failure of the transport (XTX-14). */ export class TzmeshUnreachable extends Error { readonly reason: 'unexpected' | 'internal_error' | 'transport'; readonly status?: number; constructor(reason: 'unexpected' | 'internal_error' | 'transport', options: { status?: number; cause?: unknown; detail?: string } = {}) { super(`tzmesh unreachable (${reason})${options.detail ? `: ${options.detail}` : ''}`, options.cause === undefined ? undefined : { cause: options.cause }); this.name = 'TzmeshUnreachable'; this.reason = reason; this.status = options.status; } } /** The authorization header's name and scheme (XTX-02). */ export const AUTHORIZATION_HEADER = 'authorization'; export const BEARER_SCHEME = 'Bearer'; export interface TzmeshClientOptions { token?: string; secret?: string; /** The stream's reopening waits (XTX-14): `baseMs` and `maxMs` default to * the constants `reconnectBaseMs` and `reconnectMaxMs` (XTX-11), and * `random` to `Math.random`; a test shortens the waits and fixes the * jitter here. */ reconnect?: { baseMs?: number; maxMs?: number; random?: () => number }; } export interface TzmeshCallOptions { signal?: AbortSignal; } export interface TzmeshStreamOptions { after?: Record; signal?: AbortSignal; /** Called before each reopening that follows a failure, a failed * connection, an opening with no answer, or `unavailable`, with the * failure and the wait the stream then sleeps, its jitter applied, so a * program sees a stream that keeps failing (XTX-14). An error it throws * ends the stream with that error. */ onReopen?: (cause: unknown, waitMs: number) => void; } /** The stream as the client answers it: an async iterable of events with a * `close()`; it reopens itself with its cursors where the stream ends * without `close()`, its connection fails, or an opening gets no answer or * `unavailable`, after a bounded wait with jitter, and any other refusal * ends it (XTX-14). */ export interface TzmeshStream extends AsyncIterable { close(): void; } /** The client (XTX-14): one method per call, with `reply`, `history`, and * the stream beside them. Its token and its secret are held in no member of * this object, so serialising it prints neither. */ export interface TzmeshClient { register(body: TzmeshRegisterBody | TzmeshResumeBody | TzmeshAttachBody, options?: TzmeshCallOptions): Promise; /** Under the secret, the session's end or the listener's detaching; with * `participant`, under the token, every listener of the agent it names * (XTX-03). */ end(body?: TzmeshEndBody, options?: TzmeshCallOptions): Promise<{ participant: TzmeshParticipant; listener?: string }>; /** The account's handle and its two choices (XTX-21), under the token * where the client holds one and the person's own secret where it holds * none. */ accountSet(body: TzmeshAccountSetBody, options?: TzmeshCallOptions): Promise<{ account: TzmeshAccount }>; send(body: TzmeshSendBody, options?: TzmeshCallOptions): Promise; reply(message: TzmeshMessage, body: Omit, options?: TzmeshCallOptions): Promise; read(body?: TzmeshReadBody, options?: TzmeshCallOptions): Promise; history(channel: string, count: number, options?: TzmeshCallOptions): Promise; ack(body: { channel: string; seq: number }, options?: TzmeshCallOptions): Promise; stream(options?: TzmeshStreamOptions): TzmeshStream; create(body: TzmeshCreateBody, options?: TzmeshCallOptions): Promise<{ channel: TzmeshChannel }>; join(body: { channel: string }, options?: TzmeshCallOptions): Promise<{ channel: TzmeshChannel }>; leave(body: { channel: string }, options?: TzmeshCallOptions): Promise<{ channel: TzmeshChannel }>; invite(body: { channel: string; participant: string }, options?: TzmeshCallOptions): Promise<{ channel: TzmeshChannel; invited: TzmeshParticipant }>; members(body: { channel: string }, options?: TzmeshCallOptions): Promise; /** A channel's mode and its named posters (XTX-05), under the secret * where the client holds one and the token where it holds none. */ channelSet(body: TzmeshChannelSetBody, options?: TzmeshCallOptions): Promise; /** A public channel's moderation (#7522), each under the secret where the * client holds one and the token where it holds none: a message removed * for good, and an account put out of the channel for a time or for * good. */ removeMessage(body: TzmeshRemoveMessageBody, options?: TzmeshCallOptions): Promise; removeMember(body: TzmeshRemoveMemberBody, options?: TzmeshCallOptions): Promise; /** A member's report of another member's message of a public channel to * its moderators (#7522), under the secret. */ reportMessage(body: TzmeshReportMessageBody, options?: TzmeshCallOptions): Promise; directory(body?: { query?: string; handle?: string; kind?: TzmeshDirectoryEntry['kind'] }, options?: TzmeshCallOptions): Promise; export(options?: TzmeshCallOptions): Promise; /** The site's calls (XTX-16): a guest minted under no credential, by its * first message where the body carries one, and the account's human * participant under the site's token, each keeping the secret it is * answered, and the account's participants. */ guest(body?: TzmeshGuestBody, options?: TzmeshCallOptions): Promise; human(body?: { guest?: string }, options?: TzmeshCallOptions): Promise; /** The account's participants; with `conversations_of`, under the token * or the person's own secret, that participant's direct conversations * too (XTX-16). */ participants(body?: TzmeshParticipantsBody, options?: TzmeshCallOptions): Promise; profile: { set(body: TzmeshProfileBody, options?: TzmeshCallOptions): Promise<{ participant: TzmeshParticipant }>; }; /** A standing participant's webhook (XTX-18): `url` an `https` address, * or `null` to clear it. */ webhook: { set(body: { url: string | null }, options?: TzmeshCallOptions): Promise; }; /** Upload an image, its bytes as the body (XTX-19), answered as the * stored reference a send's `images` or a profile's `image` names. */ image(bytes: Uint8Array, contentType: TzmeshImageType, options?: TzmeshCallOptions): Promise; /** The listener's wake URL and `hold`, the hold line ready to run, under * the secret of a session or of an agent's listener, a program's among * them (XTX-09). Each call answers the same URL. */ wakeUrl(options?: TzmeshCallOptions): Promise; } /** The refusal body's shape on the wire (XTX-01). */ interface RefusalBody { error?: unknown; detail?: unknown; } const isRefusalName = (name: unknown): name is TzmeshRefusalName => typeof name === 'string' && Object.prototype.hasOwnProperty.call(TZMESH_REFUSALS, name); /** Parse an answer's text as JSON, or answer null where it is none. */ const parseJson = (text: string): unknown => { try { return JSON.parse(text) as unknown; } catch { return null; } }; /** Read one answer as the wire states it: 200 with a JSON object is the * answer; a refusal name at its own status is a `TzmeshRefusal`; anything * else is `TzmeshUnreachable` (XTX-01; XTX-14). */ async function readAnswer(response: TzmeshTransportResponse): Promise { const text = await response.text(); const body = parseJson(text); if (response.status === 200) { if (body === null || typeof body !== 'object' || Array.isArray(body)) throw new TzmeshUnreachable('unexpected', { status: 200, detail: 'a success without a JSON object' }); return body as T; } const refusal = body !== null && typeof body === 'object' ? (body as RefusalBody) : null; if (refusal !== null && isRefusalName(refusal.error)) { if (TZMESH_REFUSALS[refusal.error] !== response.status) { throw new TzmeshUnreachable('unexpected', { status: response.status, detail: `${refusal.error} answered at ${response.status}` }); } const detail = typeof refusal.detail === 'string' ? refusal.detail.slice(0, 400) : ''; throw new TzmeshRefusal(refusal.error, response.status, detail); } if (refusal !== null && refusal.error === 'internal_error') throw new TzmeshUnreachable('internal_error', { status: response.status }); throw new TzmeshUnreachable('unexpected', { status: response.status }); } /** The SSE frame parser (XTX-08): frames are separated by an empty line; * `event:`, `id:`, and `data:` lines are read, and a comment line is * skipped. Answers the events a chunk completes and keeps the rest. */ export function parseEventFrames(buffered: string): { events: TzmeshEvent[]; rest: string } { const events: TzmeshEvent[] = []; const frames = buffered.split(/\r?\n\r?\n/); const rest = frames.pop() ?? ''; for (const frame of frames) { let event = ''; let id = ''; const data: string[] = []; for (const line of frame.split(/\r?\n/)) { if (line.startsWith(':') || line === '') continue; const colon = line.indexOf(':'); const field = colon < 0 ? line : line.slice(0, colon); let value = colon < 0 ? '' : line.slice(colon + 1); if (value.startsWith(' ')) value = value.slice(1); if (field === 'event') event = value; else if (field === 'id') id = value; else if (field === 'data') data.push(value); } if (data.length === 0) continue; const parsed = parseJson(data.join('\n')); if (parsed === null || typeof parsed !== 'object') continue; if (!(TZMESH_EVENTS as readonly string[]).includes(event)) continue; events.push({ event, id, data: parsed } as TzmeshEvent); } return { events, rest }; } const decoder = typeof TextDecoder === 'undefined' ? null : new TextDecoder(); const chunkText = (chunk: string | Uint8Array): string => (typeof chunk === 'string' ? chunk : decoder === null ? '' : decoder.decode(chunk, { stream: true })); /** Construct the client over a transport (XTX-14). */ export function createTzmeshClient(transport: TzmeshTransport, options: TzmeshClientOptions = {}): TzmeshClient { // The credential lives in this closure alone (XTX-14): no member of the // client carries it, so a log of the client prints neither value. const credential: { token?: string; secret?: string } = { token: options.token, secret: options.secret }; const reconnect = { baseMs: options.reconnect?.baseMs ?? TZMESH_CONSTANTS.reconnectBaseMs, maxMs: options.reconnect?.maxMs ?? TZMESH_CONSTANTS.reconnectMaxMs, random: options.reconnect?.random ?? Math.random, }; const headersFor = (call: TzmeshCall | 'stream', chosen?: TzmeshCredentialKind): Record => { const kind = chosen ?? TZMESH_CALL_CREDENTIALS[call]; const value = kind === 'none' ? undefined : kind === 'token' ? credential.token : kind === 'secret' ? credential.secret : kind === 'owner' ? credential.token ?? credential.secret : credential.secret ?? credential.token; const headers: Record = { 'content-type': 'application/json' }; if (value !== undefined) headers[AUTHORIZATION_HEADER] = `${BEARER_SCHEME} ${value}`; return headers; }; const call = async (name: TzmeshCall, body: unknown, callOptions: TzmeshCallOptions = {}, chosen?: TzmeshCredentialKind): Promise => { let response: TzmeshTransportResponse; try { response = await transport(tzmeshPath(name), { method: 'POST', headers: headersFor(name, chosen), body: JSON.stringify(body ?? {}), signal: callOptions.signal }); } catch (cause) { if (callOptions.signal?.aborted) throw callOptions.signal.reason ?? cause; throw new TzmeshUnreachable('transport', { cause }); } return readAnswer(response); }; const stream = (streamOptions: TzmeshStreamOptions = {}): TzmeshStream => { const after: Record = { ...(streamOptions.after ?? {}) }; let closed = false; const abort = new AbortController(); // `close()` resolves this promise, so a body that a transport keeps open // after the abort cannot hold the consumer's loop: each chunk is raced // against it. let markClosed: () => void = () => {}; const closedPromise = new Promise((resolve) => { markClosed = resolve; }); const close = (): void => { closed = true; abort.abort(new Error('closed')); markClosed(); }; // The caller's signal closes the stream as close() does, so its abort // ends the iteration rather than reading as a failed opening that the // loop would reopen. if (streamOptions.signal?.aborted) close(); else streamOptions.signal?.addEventListener('abort', close, { once: true }); // The reopening waits (XTX-14; the constants of XTX-11): none before the // first opening; `backoff` before each reopening, doubled after an // opening that delivered nothing and reset to the base by a delivered // event, never past the ceiling; each wait shortened by a random part of // up to half, so replicas that lost one service do not reopen together. let wait = 0; let backoff = reconnect.baseMs; // The wait is shortened once, as it is set, so `onReopen` is told the // wait the stream then sleeps. const jittered = (ms: number): number => Math.max(0, ms - Math.floor(reconnect.random() * (ms / 2))); const sleep = async (ms: number): Promise => { let timer: ReturnType | undefined; await Promise.race([ new Promise((resolve) => { timer = setTimeout(resolve, ms); }), closedPromise, ]); if (timer !== undefined) clearTimeout(timer); }; async function* events(): AsyncGenerator { // However the iteration ends, a consumer's `break` and a thrown // refusal among them, the stream is closed, so no response body stays // open behind it. try { yield* reopening(); } finally { if (!closed) close(); } } async function* reopening(): AsyncGenerator { while (!closed) { if (wait > 0) { await sleep(wait); if (closed) return; } // An opening that gets no answer, a stream whose connection fails, // and the service's `unavailable` are a restart or a move to the // warm standby: each is reopened after the wait the openings so far // earn, the program told through `onReopen`, and every other refusal // ends the stream (XTX-14; #5022). let delivered = false; const reopenAfterFailure = (cause: unknown): void => { if (delivered) backoff = reconnect.baseMs; wait = jittered(backoff); backoff = Math.min(backoff * 2, reconnect.maxMs); streamOptions.onReopen?.(cause, wait); }; let response: TzmeshTransportResponse; try { response = await transport(TZMESH_STREAM_PATH, { method: 'POST', headers: headersFor('stream'), body: JSON.stringify({ after }), signal: abort.signal }); } catch (cause) { if (closed) return; reopenAfterFailure(cause); continue; } if (response.status !== 200) { try { await readAnswer(response); } catch (refused) { if (closed) return; // A refusal is the service's word; anything else thrown while // its answer was read is the connection failing. const connectionFailed = !(refused instanceof TzmeshRefusal) && !(refused instanceof TzmeshUnreachable); if (connectionFailed || (refused instanceof TzmeshRefusal && refused.refusal === 'unavailable')) { reopenAfterFailure(refused); continue; } throw refused; } throw new TzmeshUnreachable('unexpected', { status: response.status, detail: 'the stream answered 200 with no body' }); } if (!response.body) throw new TzmeshUnreachable('unexpected', { status: 200, detail: 'the stream answered no body' }); let buffered = ''; const chunks = response.body[Symbol.asyncIterator](); let failure: { cause: unknown } | null = null; for (;;) { let next: Awaited> | null; try { next = await Promise.race([chunks.next(), closedPromise.then(() => null)]); } catch (cause) { // The connection failed mid-stream, as a killed process's or a // takeover's does. Only the read is caught here, so an error the // consumer throws into the iterator is never taken for one. failure = { cause }; break; } if (next === null || closed) { await chunks.return?.(); return; } if (next.done) break; buffered += chunkText(next.value); const { events: parsed, rest } = parseEventFrames(buffered); buffered = rest; for (const event of parsed) { // The cursor resume: every event's id is `:`, and // the newest seen per channel is what a reopened stream asks // for, so a resumed stream repeats nothing (XTX-08; XTX-14). const colon = event.id.lastIndexOf(':'); if (colon > 0) { const channel = event.id.slice(0, colon); const seq = Number(event.id.slice(colon + 1)); if (Number.isInteger(seq) && seq > (after[channel] ?? 0)) after[channel] = seq; } if (closed) return; delivered = true; yield event; } } if (closed) return; if (failure !== null) { reopenAfterFailure(failure.cause); continue; } // The stream ended without close(): reopen it with the cursors seen, // after the wait the openings so far earn. if (delivered) backoff = reconnect.baseMs; wait = jittered(backoff); backoff = Math.min(backoff * 2, reconnect.maxMs); } } const iterable = events(); return { [Symbol.asyncIterator]: () => iterable, close, }; }; const client: TzmeshClient = { async register(body, callOptions) { const answer = await call('register', body, callOptions); credential.secret = answer.secret; return answer; }, // The owner's end of an agent goes under the token; a session's or a // listener's own under the secret (XTX-03). end: (body = {}, callOptions) => call('end', body, callOptions, body.participant !== undefined ? 'token' : 'secret'), accountSet: (body, callOptions) => call('account.set', body, callOptions), send: (body, callOptions) => call('send', body, callOptions), reply: (message, body, callOptions) => call('send', { ...body, channel: message.channel, reply_to: message.id }, callOptions), read: (body = {}, callOptions) => call('read', body, callOptions), async history(channel, count, callOptions) { const answer = await call('read', { channel, last: count }, callOptions); return answer.messages; }, ack: (body, callOptions) => call('ack', body, callOptions), stream, // A public channel's create is the owner's act, under the token; a // private one's the participant's, under the secret (XTX-02; XTX-05). create: (body, callOptions) => call('create', body, callOptions, body.kind === 'public' ? 'token' : 'secret'), join: (body, callOptions) => call('join', body, callOptions), leave: (body, callOptions) => call('leave', body, callOptions), invite: (body, callOptions) => call('invite', body, callOptions), members: (body, callOptions) => call('members', body, callOptions), channelSet: (body, callOptions) => call('channel.set', body, callOptions), removeMessage: (body, callOptions) => call('remove_message', body, callOptions), removeMember: (body, callOptions) => call('remove_member', body, callOptions), reportMessage: (body, callOptions) => call('report_message', body, callOptions), directory: (body = {}, callOptions) => call('directory', body, callOptions), export: (callOptions) => call('export', {}, callOptions), async guest(body = {}, callOptions) { const answer = await call('guest', body, callOptions); credential.secret = answer.secret; return answer; }, async human(body = {}, callOptions) { const answer = await call('human', body, callOptions); credential.secret = answer.secret; return answer; }, participants: (body = {}, callOptions) => call('participants', body, callOptions), profile: { set: (body, callOptions) => call('profile.set', body, callOptions), }, webhook: { set: (body, callOptions) => call('webhook_set', body, callOptions), }, async image(bytes, contentType, callOptions = {}) { const headers = { ...headersFor('image'), 'content-type': contentType }; let response: TzmeshTransportResponse; try { response = await transport(TZMESH_IMAGE_PATH, { method: 'POST', headers, body: bytes, signal: callOptions.signal }); } catch (cause) { if (callOptions.signal?.aborted) throw callOptions.signal.reason ?? cause; throw new TzmeshUnreachable('transport', { cause }); } return readAnswer(response); }, wakeUrl: (callOptions) => call('wake_url', {}, callOptions), }; return client; } // --------------------------------------------------------------------------- // The webhook's signature (XTX-18). A delivery carries // `tzmesh-signature: t=,v1=`: `t` the Unix seconds of the // attempt, and `v1` the lower-case hex HMAC-SHA256 of the text `.`, // the body exactly as the request carried it, keyed by the signing secret's // UTF-8 bytes, the whole secret with its prefix. A receiver recomputes the // HMAC over the raw body, compares it in constant time, and refuses a `t` // more than `webhookToleranceMs` from its own clock, so a captured delivery // is not replayed later. Both helpers run on WebCrypto, which Node.js 24 // carries as `globalThis.crypto`. const hex = (bytes: ArrayBuffer): string => [...new Uint8Array(bytes)].map((b) => b.toString(16).padStart(2, '0')).join(''); const hmacHex = async (secret: string, text: string): Promise => { const encoder = new TextEncoder(); const key = await globalThis.crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); return hex(await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(text))); }; /** The `tzmesh-signature` value for a body at an instant (XTX-18), what the * service sends and what a test of a receiver signs with. */ export async function signTzmeshWebhook(secret: string, body: string, atMs: number): Promise { const t = Math.floor(atMs / 1000); return `t=${t},v1=${await hmacHex(secret, `${t}.${body}`)}`; } /** Whether a delivery's signature holds (XTX-18): the `v1` HMAC over * `.` under the secret, and `t` within the tolerance of `nowMs`, * `webhookToleranceMs` where the caller names none. */ export async function verifyTzmeshWebhook(request: { secret: string; body: string; signature: string | null | undefined; nowMs?: number; toleranceMs?: number }): Promise { if (typeof request.signature !== 'string') return false; const parts = new Map(); for (const part of request.signature.split(',')) { const eq = part.indexOf('='); if (eq <= 0) continue; const key = part.slice(0, eq).trim(); parts.set(key, [...(parts.get(key) ?? []), part.slice(eq + 1).trim()]); } const t = Number(parts.get('t')?.[0]); if (!Number.isInteger(t)) return false; const tolerance = request.toleranceMs ?? TZMESH_CONSTANTS.webhookToleranceMs; if (Math.abs((request.nowMs ?? Date.now()) - t * 1000) > tolerance) return false; const expected = await hmacHex(request.secret, `${t}.${request.body}`); return (parts.get('v1') ?? []).some((given) => { if (given.length !== expected.length) return false; let diff = 0; for (let i = 0; i < given.length; i += 1) diff |= given.charCodeAt(i) ^ expected.charCodeAt(i); return diff === 0; }); } /** `connect(transport, token, secret)`: the client under a token and, * where the program holds one, its participant secret (XTX-14). */ export const connect = (transport: TzmeshTransport, token: string, secret?: string): TzmeshClient => createTzmeshClient(transport, { token, secret }); // --- The hosted agent's helpers (XTX-20) ------------------------------------ // // What a program that answers messages needs beside the calls: one handler run // for each event, whether the event arrives on the stream the program holds // (`runTzmeshAgent`) or in a webhook request the service makes to the // program's route (`createTzmeshWebhookHandler`); a reply whose `client_id` // derives from the event it answers, so a redelivered event, a retried // webhook, or a second listener answering the same event creates nothing // twice (XTX-06); the acknowledgement after the handler has acted (XTX-07); // and `state`, a value per conversation kept in the store // the program names: the application's storage on Turn Zero Cloud, a folder // on a local run, or memory in a test. /** The `client_id` a reply to an event takes (XTX-20): the event's name and * id, the channel and the sequence number, with the reply's place among * the replies the handler makes to that event, so the same event answered * again names the same ids and the service creates nothing twice (XTX-06). * The id fits `clientIdMax` (XTX-11). */ export const tzmeshReplyClientId = (event: Pick, part = 0): string => `re:${event.event}:${event.id}:${part}`.slice(0, TZMESH_CONSTANTS.clientIdMax); /** The channel an event happened in (XTX-08): every event of the stream * names one; null for an event of another shape a caller hands in. */ export const tzmeshEventChannel = (event: TzmeshEvent): string | null => { if (event.event === 'message') return event.data.message.channel; return typeof event.data?.channel === 'string' ? event.data.channel : null; }; /** The sequence number an event acknowledges: a message's, or a `system` * message's (a joining, a leaving, an end, or an answering), read from the * event's data (XTX-05; XTX-08). */ const ackableSeq = (event: TzmeshEvent): number | null => { if (event.event === 'message' || (TZMESH_SYSTEM_EVENTS as readonly string[]).includes(event.event)) return (event.data as { message: TzmeshMessage }).message.seq; return null; }; /** A reply's body: the message's text alone, or the send's members but the address and * the reply's target, which the reply derives with the client id where * none is given (XTX-06; XTX-20). */ export type TzmeshReplyBody = string | (Omit & { client_id?: string }); /** What a handler is given beside the event (XTX-20). */ export interface TzmeshAgentContext { /** The client the helpers run under, the participant's secret its * credential. */ client: TzmeshClient; /** The per-conversation state, where the program named a store. */ state?: TzmeshState; /** How the event arrived: on the stream, or in a webhook request. */ via: 'stream' | 'webhook'; /** Sends into the event's channel, a message's reply naming it in * `reply_to`, under the derived client id; a second reply to the same * event takes the next part. */ reply(body: TzmeshReplyBody, options?: TzmeshCallOptions): Promise; } /** The handler a program runs for each event (XTX-20). It resolves once it * has acted; a handler that throws leaves the event unacknowledged. */ export type TzmeshAgentHandler = (event: TzmeshEvent, context: TzmeshAgentContext) => unknown; export interface TzmeshDispatchOptions { client: TzmeshClient; handler: TzmeshAgentHandler; state?: TzmeshState; /** Acknowledge each message, joining, and leaving after the handler has * acted; true where absent. */ ack?: boolean; signal?: AbortSignal; } /** Run the handler for one event and acknowledge it after (XTX-20): the one * path the stream's loop and the webhook's handler share. */ export async function dispatchTzmeshEvent(event: TzmeshEvent, via: 'stream' | 'webhook', options: TzmeshDispatchOptions): Promise { const { client, handler, state, signal } = options; let part = 0; const context: TzmeshAgentContext = { client, state, via, reply(body, callOptions) { const channel = tzmeshEventChannel(event); if (channel === null) return Promise.reject(new Error(`a ${event.event} event names no one channel to reply in`)); const members = typeof body === 'string' ? { text: body } : body; const clientId = members.client_id ?? tzmeshReplyClientId(event, part); part += 1; const send = { ...members, client_id: clientId }; const callSignal = { signal: callOptions?.signal ?? signal }; if (event.event === 'message') return client.reply(event.data.message, send, callSignal); return client.send({ ...send, channel }, callSignal); }, }; await handler(event, context); // The acknowledgement takes no abort signal: the handler has acted, so a // loop the handler itself stopped still records it (XTX-07). const seq = ackableSeq(event); const channel = tzmeshEventChannel(event); if ((options.ack ?? true) && seq !== null && channel !== null) await client.ack({ channel, seq }); } export interface TzmeshAgentOptions extends TzmeshDispatchOptions { /** The cursors the stream opens past (XTX-08); absent, the service sends * everything past the participant's acknowledged cursors. */ after?: Record; /** Called with a handler's failure; the loop then goes on and the event * stays unacknowledged. Absent, the failure ends the loop. */ onError?: (error: unknown, event: TzmeshEvent) => void; /** Called before each reopening of the stream that follows a failure, * with the failure and the wait (XTX-14). */ onReopen?: (cause: unknown, waitMs: number) => void; } /** The loop (XTX-20): holds the stream, which reopens itself with its * cursors through a restart or a move to the standby (XTX-14), and runs * the handler for each event in order, acknowledging each after the * handler has acted. It ends when the signal aborts, answering normally; * with no `onError`, when a handler throws, the failure its rejection and * the event left unacknowledged, so the next loop's stream sends it again; * and when the stream throws a refusal other than `unavailable`, such as * `credential_refused` once the participant has ended, or an answer * outside the wire, the failure its rejection. */ export async function runTzmeshAgent(options: TzmeshAgentOptions): Promise { const { client, signal } = options; if (signal?.aborted) return; const stream = client.stream({ after: options.after, signal, onReopen: options.onReopen }); const stop = (): void => stream.close(); signal?.addEventListener('abort', stop, { once: true }); // A channel's cursor is one number (XTX-07), so an acknowledgement of a // later event would pass over a failed one: after a failure in a channel, // the loop acknowledges nothing more there, and the next loop's stream // sends the failed event and those after it again. const held = new Set(); try { for await (const event of stream) { const channel = tzmeshEventChannel(event); try { await dispatchTzmeshEvent(event, 'stream', channel !== null && held.has(channel) ? { ...options, ack: false } : options); } catch (error) { if (signal?.aborted) return; if (options.onError === undefined) throw error; if (channel !== null) held.add(channel); options.onError(error, event); } } } finally { signal?.removeEventListener('abort', stop); stream.close(); } } // --- the webhook's handler (XTX-20) ----------------------------------------- /** A webhook request as the program's server read it: the headers by name, * in any case, and the body as the bytes' text, unparsed, since the * signature is over the bytes as sent. */ export interface TzmeshWebhookRequest { headers: Record | { get(name: string): string | null }; body: string; } /** The answer the program's server writes back. */ export interface TzmeshWebhookAnswer { status: number; headers: Record; body: string; } /** The signature check (XTX-20): `tzmeshWebhookVerifier(secret)` checks the * delivery's own scheme (XTX-18), and a program may name another, a test's * among them. Nothing is run on a request the check answers false for, or * throws on. */ export type TzmeshWebhookVerifier = (request: { headers: TzmeshWebhookRequest['headers']; body: string; header(name: string): string | null }) => boolean | Promise; /** The verifier over the webhook's signing secret (XTX-18; XTX-20): the * `tzmesh-signature` header checked by `verifyTzmeshWebhook` over the body * as sent, within the tolerance, at `now` where a test names one. */ export const tzmeshWebhookVerifier = (secret: string, options: { now?: () => number; toleranceMs?: number } = {}): TzmeshWebhookVerifier => ({ body, header }) => verifyTzmeshWebhook({ secret, body, signature: header(TZMESH_WEBHOOK_SIGNATURE_HEADER), nowMs: options.now?.(), toleranceMs: options.toleranceMs }); export interface TzmeshWebhookOptions extends TzmeshDispatchOptions { verify: TzmeshWebhookVerifier; /** The program's name check (#7522's decision 6), where it is the * checker the service's configuration names: given a `channel_name` * delivery's check, it answers the verdict the handler writes back. * Where absent, a `channel_name` delivery answers 400 `invalid_event`. */ checkName?: (check: TzmeshNameCheck, context: { id: string }) => TzmeshNameVerdict | Promise; } /** The name check's delivery (#7522's decision 6): its event name, which no * stream carries, so `TZMESH_EVENTS` holds it not. The service POSTs it to * the configured checker's webhook, signed as every delivery is, its id a * check id, and reads the answer as the verdict. */ export const TZMESH_NAME_CHECK_EVENT = 'channel_name'; /** What a name check asks (#7522's decision 6): the call it gates, * `create` or `channel.set`, a public channel's name, its description, * null where none is given, and the owning account's handle. At a public * `create` the verdict judges the name with its description; at a public * channel's `channel.set` naming a description, the description alone, the * name carried as context (XTX-05; XTX-18). */ export interface TzmeshNameCheck { call: 'create' | 'channel.set'; name: string; description: string | null; account: string; } /** The checker's verdict, the 2xx answer's JSON body: an approval, or a * refusal with `reason`, one line of 1 to `noteMax` characters, which the * service answers as `name_refused`'s detail. Any other answer reads as * no verdict, and the call answers `unavailable`. */ export type TzmeshNameVerdict = { approved: true } | { approved: false; reason: string }; /** Decode a webhook body as a name check (#7522's decision 6): a JSON * object whose `event` is `channel_name`, with `id` and `data` naming * `call`, `name`, `description`, and `account`. Answers null for anything * else. */ export function decodeTzmeshNameCheck(body: string): { id: string; check: TzmeshNameCheck } | null { const parsed = parseJson(body); if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null; const { event, id, data } = parsed as { event?: unknown; id?: unknown; data?: unknown }; if (event !== TZMESH_NAME_CHECK_EVENT || typeof id !== 'string' || data === null || typeof data !== 'object' || Array.isArray(data)) return null; const { call, name, description, account } = data as { call?: unknown; name?: unknown; description?: unknown; account?: unknown }; if ((call !== 'create' && call !== 'channel.set') || typeof name !== 'string' || typeof account !== 'string' || (description !== null && typeof description !== 'string')) return null; return { id, check: { call, name, description: description as string | null, account } }; } const headerOf = (headers: TzmeshWebhookRequest['headers'], name: string): string | null => { if (typeof (headers as { get?: unknown }).get === 'function') return (headers as { get(name: string): string | null }).get(name); const wanted = name.toLowerCase(); for (const [key, value] of Object.entries(headers as Record)) { if (key.toLowerCase() !== wanted || value === undefined) continue; return Array.isArray(value) ? value.join(', ') : value; } return null; }; const webhookAnswer = (status: number, body: Record): TzmeshWebhookAnswer => ({ status, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); /** Decode a webhook body as one event (XTX-20): a JSON object carrying * `event`, one of the stream's event names, `id`, and `data`, the frame's * three lines as one object (XTX-08). Answers null for anything else. */ export function decodeTzmeshWebhookEvent(body: string): TzmeshEvent | null { const parsed = parseJson(body); if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null; const { event, id, data } = parsed as { event?: unknown; id?: unknown; data?: unknown }; if (typeof event !== 'string' || !(TZMESH_EVENTS as readonly string[]).includes(event)) return null; if (typeof id !== 'string' || data === null || typeof data !== 'object' || Array.isArray(data)) return null; return { event, id, data } as TzmeshEvent; } /** The webhook's handler (XTX-20): verifies the request, decodes the event, * runs the same handler the loop runs, and answers. A request the verifier * does not admit answers 401 `signature_refused`, and a body that is no * event 400 `invalid_event`, each running nothing; a handler that throws * answers 500 `handler_failed`, so the service delivers the event again; * an event handled answers 200, which acknowledges it at the service, so * the handler acknowledges nothing itself unless `ack` is true (XTX-18). */ export function createTzmeshWebhookHandler(options: TzmeshWebhookOptions): (request: TzmeshWebhookRequest) => Promise { return async (request) => { let admitted = false; try { admitted = (await options.verify({ headers: request.headers, body: request.body, header: (name) => headerOf(request.headers, name) })) === true; } catch { admitted = false; } if (!admitted) return webhookAnswer(401, { error: 'signature_refused', detail: 'The request carries no signature this agent admits; nothing was run.' }); // A name check, the configured checker's alone (#7522's decision 6): // the program's verdict is the answer, a 2xx the service reads. const named = decodeTzmeshNameCheck(request.body); if (named !== null) { if (options.checkName === undefined) return webhookAnswer(400, { error: 'invalid_event', detail: 'This agent checks no channel names; nothing was run.' }); let verdict: TzmeshNameVerdict; try { verdict = await options.checkName(named.check, { id: named.id }); } catch { return webhookAnswer(500, { error: 'handler_failed', detail: 'The agent did not finish the name check.' }); } return webhookAnswer(200, verdict.approved ? { approved: true } : { approved: false, reason: verdict.reason }); } const event = decodeTzmeshWebhookEvent(request.body); if (event === null) return webhookAnswer(400, { error: 'invalid_event', detail: 'The body is no event of the stream; nothing was run.' }); try { await dispatchTzmeshEvent(event, 'webhook', { ...options, ack: options.ack ?? false }); } catch { return webhookAnswer(500, { error: 'handler_failed', detail: 'The agent did not finish acting on the event; deliver it again.' }); } return webhookAnswer(200, { ok: true, id: event.id }); }; } // --- state (XTX-20) --------------------------------------------------------- /** A stored value and its version. */ export interface TzmeshStoredValue { value: unknown; version: string; } /** The store `state` keeps its values in: a load by key, answering null * where nothing is stored, and a save that holds only where the stored * version is still the one the caller read, null for nothing stored, * answering the new version. A save whose version moved throws * `TzmeshStateConflict`. */ export interface TzmeshStateStore { load(key: string): Promise; save(key: string, value: unknown, expected: string | null): Promise; } /** A save lost to another writer of the same key. */ export class TzmeshStateConflict extends Error { readonly key: string; constructor(key: string) { super(`the state of ${key} moved under this write`); this.name = 'TzmeshStateConflict'; this.key = key; } } /** The per-conversation state (XTX-20): one JSON value per conversation, a * channel's id or another key the program chooses. `update` reads, * applies, and saves under the version it read, starting again from the * read where another writer saved first, at most five times. */ export interface TzmeshState { get(conversation: string): Promise; set(conversation: string, value: T): Promise; update(conversation: string, apply: (current: T | undefined) => T | Promise): Promise; } const STATE_KEY_PATTERN = /^[A-Za-z0-9_.:#@-]{1,200}$/; const STATE_UPDATE_ATTEMPTS = 5; const checkStateKey = (conversation: string): string => { if (typeof conversation !== 'string' || !STATE_KEY_PATTERN.test(conversation) || conversation === '.' || conversation === '..') { throw new Error('a conversation key is 1 to 200 letters, digits, and the characters _ . : # @ -, and never . or ..'); } return conversation; }; /** The state over a store (XTX-20). */ export function createTzmeshState(store: TzmeshStateStore): TzmeshState { const update = async (conversation: string, apply: (current: T | undefined) => T | Promise): Promise => { const key = checkStateKey(conversation); for (let attempt = 1; ; attempt += 1) { const stored = await store.load(key); const next = await apply(stored === null || stored.value === null ? undefined : (stored.value as T)); try { await store.save(key, next === undefined ? null : next, stored === null ? null : stored.version); return next; } catch (error) { if (!(error instanceof TzmeshStateConflict) || attempt >= STATE_UPDATE_ATTEMPTS) throw error; } } }; return { async get(conversation: string) { const stored = await store.load(checkStateKey(conversation)); return stored === null || stored.value === null ? undefined : (stored.value as T); }, async set(conversation: string, value: T) { await update(conversation, () => value); }, update, }; } /** A store in memory, for a test or a run that keeps nothing (XTX-20). */ export function memoryStateStore(): TzmeshStateStore { const values = new Map(); return { async load(key) { const held = values.get(key); return held === undefined ? null : { value: JSON.parse(held.text) as unknown, version: String(held.version) }; }, async save(key, value, expected) { const held = values.get(key); if ((held === undefined ? null : String(held.version)) !== expected) throw new TzmeshStateConflict(key); const version = (held?.version ?? 0) + 1; values.set(key, { text: JSON.stringify(value), version }); return String(version); }, }; } /** A store in a folder on the program's machine, one JSON file per key, for * a local run (XTX-20). Each file holds the value and a version counter; a * save writes a temporary file beside it and renames it over the old, and * one process's saves to a key run one at a time. The program hands in * the file system's promise module, `node:fs/promises` or one shaped like * it, so this module imports nothing and builds for a browser too. */ export interface TzmeshFileSystem { readFile(path: string, encoding: 'utf8'): Promise; writeFile(path: string, data: string): Promise; rename(from: string, to: string): Promise; mkdir(path: string, options: { recursive: true }): Promise; } export function fileStateStore(folder: string, files: TzmeshFileSystem): TzmeshStateStore { const loadFs = async (): Promise => files; const base = folder.endsWith('/') ? folder.slice(0, -1) : folder; const pathOf = (key: string): string => `${base}/${encodeURIComponent(key)}.json`; const queues = new Map>(); const serial = (key: string, act: () => Promise): Promise => { const run = (queues.get(key) ?? Promise.resolve()).then(act, act); queues.set(key, run.catch(() => undefined)); return run; }; const read = async (key: string): Promise<{ value: unknown; version: number } | null> => { const { readFile } = await loadFs(); try { const held = JSON.parse(await readFile(pathOf(key), 'utf8')) as { value: unknown; version: number }; return { value: held.value, version: held.version }; } catch (error) { if ((error as { code?: unknown }).code === 'ENOENT') return null; throw error; } }; return { async load(key) { const held = await read(key); return held === null ? null : { value: held.value, version: String(held.version) }; }, save(key, value, expected) { return serial(key, async () => { const { mkdir, rename, writeFile } = await loadFs(); const held = await read(key); if ((held === null ? null : String(held.version)) !== expected) throw new TzmeshStateConflict(key); const version = (held?.version ?? 0) + 1; await mkdir(base, { recursive: true }); const temporary = `${pathOf(key)}.${version}.tmp`; await writeFile(temporary, JSON.stringify({ version, value })); await rename(temporary, pathOf(key)); return String(version); }); }, }; } /** What the store over Turn Zero Cloud's storage reads of the storage * package's client, its `put` and `get`, structurally, so this module * imports nothing of it. */ export interface TzmeshStorageClient { put(area: string, name: string, bytes: Uint8Array, options: { identity: string; contentType?: string; ifVersion?: string | null; mustNotExist?: boolean }): Promise<{ version: string }>; get(area: string, name: string): Promise<{ bytes?: Uint8Array; meta: { version: string | null } }>; } /** A store in one area of the application's storage on Turn Zero Cloud, one * file per key under `prefix`, its version the file's own, written under * the acting identity the program names (XTX-20). A file that does not * stand is nothing stored, and a conditional write the storage refuses as * moved is a conflict. */ export function storageStateStore(storage: TzmeshStorageClient, options: { area: string; identity: string; prefix?: string }): TzmeshStateStore { const prefix = options.prefix ?? 'tzmesh-state/'; const nameOf = (key: string): string => `${prefix}${encodeURIComponent(key)}.json`; const encoder = new TextEncoder(); const textDecoder = new TextDecoder(); const codeOf = (error: unknown): unknown => (error as { code?: unknown } | null)?.code; return { async load(key) { try { const answer = await storage.get(options.area, nameOf(key)); if (answer.bytes === undefined || answer.meta.version === null) return null; return { value: JSON.parse(textDecoder.decode(answer.bytes)) as unknown, version: answer.meta.version }; } catch (error) { if (codeOf(error) === 'no_file') return null; throw error; } }, async save(key, value, expected) { try { const bytes = encoder.encode(JSON.stringify(value)); const written = await storage.put(options.area, nameOf(key), bytes, { identity: options.identity, contentType: 'application/json', ...(expected === null ? { mustNotExist: true } : { ifVersion: expected }) }); return written.version; } catch (error) { if (codeOf(error) === 'exists' || codeOf(error) === 'version_moved') throw new TzmeshStateConflict(key); throw error; } }, }; }