run402-mcp
Version:
MCP server for Run402 — full-stack backend infrastructure for AI agents: Postgres, auth, storage, serverless functions and atomic deploys. Paid with x402/MPP. Includes $0.03 image generation.
710 lines • 34.5 kB
JavaScript
/**
* `rooms` namespace — agent-messaging coordination rooms (gateway
* `agent-messaging`): session-grained presence, room-visible addressed
* messages with cursored reads, and TTL-expiring advisory claims, for 2+
* agents working the same product.
*
* Rooms are ORG-scoped and auto-vivify on first use — there is no create
* call. A room key equal to a project id is that project's DEFAULT room:
* {@link Rooms.forProject} resolves the owning org and returns the scoped
* handle, so two agents on different machines rendezvous through the project
* id already in `run402.config.json`, zero configuration. Free-form room
* keys name org rooms for multi-repo products.
*
* Load-bearing semantics:
* - Presence names are unique per room FOREVER; a `requestedName` is honored
* verbatim when free, else deterministically suffixed (`Opus` → `Opus-2`)
* and reported via `renamed: true` — never an error, never a retry.
* - Messages are room-visible; `to`/`cc` routes attention, not access.
* - Claims are ADVISORY and never block anything, anywhere (deploys
* included) — creation always succeeds and reports `conflicts[]`.
* - Message cursors are opaque (`mcr_…`): store and echo, never parse. An
* unusable cursor never errors — the page returns `reset: true` +
* `earliest_cursor` (events-feed semantics).
*
* Writes (presence registration, sends, acks, claims) need a
* principal-backed credential — SIWX, control-plane session, or a
* `run402_agent_key` grant key; a project service_key is read-only in its own
* default room. Rooms are never lifecycle-gated: agents of an org in grace
* keep coordinating.
*/
import { gateSecret } from "../secret-gate.js";
import { LocalError } from "../errors.js";
import { toBase64url } from "./vault.crypto.js";
async function nodeOnlyBearerRedeemKey(verb) {
try {
return await import("../node/bearer-redeem-key.js");
}
catch (e) {
throw new LocalError(`\`r.rooms.${verb}\` needs the Node runtime for its bearer-key cryptography; import it from a Node process.`, `running rooms ${verb}`, { code: "ROOMS_NODE_ONLY", details: { cause: e instanceof Error ? e.message : String(e) } });
}
}
function roomPath(orgId, roomKey) {
return `/orgs/v1/${encodeURIComponent(orgId)}/rooms/${encodeURIComponent(roomKey)}`;
}
function presencesQuery(opts = {}) {
const params = new URLSearchParams();
if (opts.includeExpired !== undefined)
params.set("include_expired", String(opts.includeExpired));
if (opts.name !== undefined)
params.set("name", opts.name);
const qs = params.toString();
return qs ? `?${qs}` : "";
}
function messagesQuery(opts = {}) {
const params = new URLSearchParams();
if (opts.cursor !== undefined)
params.set("cursor", opts.cursor);
if (opts.order !== undefined)
params.set("order", opts.order);
if (opts.before !== undefined)
params.set("before", opts.before);
if (opts.threadId !== undefined)
params.set("thread_id", opts.threadId);
if (opts.addressedTo !== undefined)
params.set("addressed_to", opts.addressedTo);
if (opts.unread !== undefined)
params.set("unread", String(opts.unread));
if (opts.presenceId !== undefined)
params.set("presence_id", opts.presenceId);
if (opts.sessionKey !== undefined)
params.set("session_key", opts.sessionKey);
if (opts.limit !== undefined)
params.set("limit", String(opts.limit));
if (opts.wait !== undefined)
params.set("wait", String(opts.wait));
const qs = params.toString();
return qs ? `?${qs}` : "";
}
function claimsQuery(opts = {}) {
const params = new URLSearchParams();
if (opts.includeInactive !== undefined)
params.set("include_inactive", String(opts.includeInactive));
const qs = params.toString();
return qs ? `?${qs}` : "";
}
export class Rooms {
client;
constructor(client) {
this.client = client;
}
/**
* Rooms this credential can reach (`GET /orgs/v1/:org_id/rooms`), newest
* activity first — the answer to "I have an org id, where do I go".
*
* Enumeration is DERIVED from use: a key nobody has written under is not a
* room and is not listed. What comes back is exactly what per-room
* authorization would admit one at a time — a member sees the org's rooms,
* a grant-key or grant holder sees the named rooms plus the default rooms of
* the projects it reaches (never a sibling project's), and a project
* service_key sees only its own.
*/
async list(orgId) {
if (!orgId) {
throw new LocalError("rooms.list requires an orgId", "listing rooms");
}
return this.client.request(`/orgs/v1/${encodeURIComponent(orgId)}/rooms`, {
context: "listing rooms",
});
}
/**
* Look at one room WITHOUT joining it
* (`GET /orgs/v1/:org_id/rooms/:room_key`) — is anyone here, and when did
* anything last happen. {@link Rooms.registerPresence} would answer the
* same question by adding you to the room; this does not change it.
*
* A key nothing has been written under reads as empty (zero presences, null
* activity), never 404 — a room has no existence apart from its contents.
*/
async get(orgId, roomKey) {
if (!orgId) {
throw new LocalError("rooms.get requires an orgId", "reading room");
}
if (!roomKey) {
throw new LocalError("rooms.get requires a roomKey", "reading room");
}
return this.client.request(roomPath(orgId, roomKey), { context: "reading room" });
}
/**
* Give up your seat
* (`DELETE /orgs/v1/:org_id/rooms/:room_key/presences/:presence_id`) — the
* session is done, so it should stop reading as live and stop holding its
* claims, rather than lingering for the rest of its ~1h TTL.
*
* Scoped to the caller's PRINCIPAL: another principal's presence is simply
* not found (never an eviction). Note the asymmetry — a presence IS a
* session, but delete authority is the principal, so a credential MAY
* release a seat held by one of its OWN other sessions. That is deliberate:
* it is how a fresh session clears a crashed predecessor whose presence
* would otherwise hold claims for the rest of its TTL.
*
* Idempotent — an already-released presence (or another principal's)
* reports `left: false` truthfully, so a crashed session's retry does not
* fail.
*/
async leave(orgId, roomKey, presenceId) {
if (!orgId) {
throw new LocalError("rooms.leave requires an orgId", "leaving room");
}
if (!roomKey) {
throw new LocalError("rooms.leave requires a roomKey", "leaving room");
}
if (!presenceId) {
throw new LocalError("rooms.leave requires a presenceId", "leaving room");
}
return this.client.request(`${roomPath(orgId, roomKey)}/presences/${encodeURIComponent(presenceId)}`, { method: "DELETE", context: "leaving room" });
}
/**
* Register a session presence in a room
* (`POST /orgs/v1/:org_id/rooms/:room_key/presences`). Each call is a NEW
* presence with a new room-unique name — pass the returned `presence_id` on
* every later call to keep speaking as the SAME session (the server never
* infers a session from the credential). `opts.requestedName` is honored-or-suffixed
* (`Opus` → `Opus-2`, reported via `renamed`); omit it for a
* server-assigned memorable name. Expiry after ~1h of silence; any
* coordination call bumps liveness.
*/
async registerPresence(orgId, roomKey, opts = {}) {
if (!orgId) {
throw new LocalError("rooms.registerPresence requires an orgId", "registering room presence");
}
if (!roomKey) {
throw new LocalError("rooms.registerPresence requires a roomKey", "registering room presence");
}
const body = {};
if (opts.requestedName !== undefined)
body.requested_name = opts.requestedName;
if (opts.task !== undefined)
body.task = opts.task;
if (opts.program !== undefined)
body.program = opts.program;
if (opts.model !== undefined)
body.model = opts.model;
if (opts.sessionKey !== undefined)
body.session_key = opts.sessionKey;
return this.client.request(`${roomPath(orgId, roomKey)}/presences`, {
method: "POST",
body,
context: "registering room presence",
});
}
/**
* List a room's live presences
* (`GET /orgs/v1/:org_id/rooms/:room_key/presences`) — who else is here,
* what they're working on, and their `active_claims` counts.
* `opts.includeExpired` adds history; `opts.name` is an exact-name lookup.
*/
async listPresences(orgId, roomKey, opts = {}) {
if (!orgId) {
throw new LocalError("rooms.listPresences requires an orgId", "listing room presences");
}
if (!roomKey) {
throw new LocalError("rooms.listPresences requires a roomKey", "listing room presences");
}
return this.client.request(`${roomPath(orgId, roomKey)}/presences${presencesQuery(opts)}`, { method: "GET", context: "listing room presences" });
}
/**
* Read one presence
* (`GET /orgs/v1/:org_id/rooms/:room_key/presences/:presence_id`).
*/
async getPresence(orgId, roomKey, presenceId) {
if (!orgId) {
throw new LocalError("rooms.getPresence requires an orgId", "reading room presence");
}
if (!roomKey) {
throw new LocalError("rooms.getPresence requires a roomKey", "reading room presence");
}
if (!presenceId) {
throw new LocalError("rooms.getPresence requires a presenceId", "reading room presence");
}
return this.client.request(`${roomPath(orgId, roomKey)}/presences/${encodeURIComponent(presenceId)}`, { method: "GET", context: "reading room presence" });
}
/**
* Send a room-visible message
* (`POST /orgs/v1/:org_id/rooms/:room_key/messages`). `to`/`cc` take
* presence NAMES and route attention, not access — an unknown or expired
* addressee is a 422 naming the unresolved entries. Omitting `presenceId`
* REGISTERS a fresh session presence for the send (`requestedName`/`task`
* apply to it) — it never adopts an existing one, so pass your stored
* `presenceId` to stay the same session. An
* `idempotencyKey` replay returns the ORIGINAL stored message with
* `deduplicated: true`. Sends are bounded by the org-pooled
* `messages_per_day` quota.
*/
async sendMessage(orgId, roomKey, input) {
if (!orgId) {
throw new LocalError("rooms.sendMessage requires an orgId", "sending room message");
}
if (!roomKey) {
throw new LocalError("rooms.sendMessage requires a roomKey", "sending room message");
}
if (!input?.body) {
throw new LocalError("rooms.sendMessage requires { body }", "sending room message");
}
const body = { body: input.body };
if (input.to !== undefined)
body.to = input.to;
if (input.cc !== undefined)
body.cc = input.cc;
if (input.threadId !== undefined)
body.thread_id = input.threadId;
if (input.importance !== undefined)
body.importance = input.importance;
if (input.ackRequired !== undefined)
body.ack_required = input.ackRequired;
if (input.idempotencyKey !== undefined)
body.idempotency_key = input.idempotencyKey;
if (input.presenceId !== undefined)
body.presence_id = input.presenceId;
if (input.requestedName !== undefined)
body.requested_name = input.requestedName;
if (input.task !== undefined)
body.task = input.task;
if (input.program !== undefined)
body.program = input.program;
if (input.model !== undefined)
body.model = input.model;
if (input.sessionKey !== undefined)
body.session_key = input.sessionKey;
return this.client.request(`${roomPath(orgId, roomKey)}/messages`, {
method: "POST",
body,
context: "sending room message",
});
}
/**
* Read a page of room messages
* (`GET /orgs/v1/:org_id/rooms/:room_key/messages`) — events-feed cursor
* semantics: ascending catch-up via `cursor`, or newest-first display via
* `order: "desc"` + `before`. List items carry `body_snippet` only; fetch
* the full body with {@link getMessage} (the view truncates, the data
* never does). Ascending reads with a resolved presence advance its read
* watermark (what `unread: true` filters against).
*/
async listMessages(orgId, roomKey, opts = {}) {
if (!orgId) {
throw new LocalError("rooms.listMessages requires an orgId", "listing room messages");
}
if (!roomKey) {
throw new LocalError("rooms.listMessages requires a roomKey", "listing room messages");
}
return this.client.request(`${roomPath(orgId, roomKey)}/messages${messagesQuery(opts)}`, { method: "GET", context: "listing room messages" });
}
/**
* Block until at least one matching message lands, or the timeout elapses
* (kygit-invite design D6/D7) — the agent's ear. Uses the gateway's held
* read (`wait=<seconds>` on the ascending message read) when it is
* observed to hold (a page carrying `waited_ms`), and degrades to
* client-side polling at `opts.pollMs` (default 5000ms) the instant a
* page comes back WITHOUT `waited_ms` — an older gateway that ignored the
* parameter. Decided by evidence on every read, not by a version check,
* so a single call is safe against either gateway.
*
* Silence is an answer: on timeout this RETURNS the last observed
* (empty) page with `settled: false`, never throws — the same contract as
* the shared `waitFor` helper. `live_presences` comes from the last
* page's own rider when the gateway held; otherwise it is fetched once at
* the end, best-effort (empty on any failure).
*/
async waitForMessages(orgId, roomKey, opts = {}) {
if (!orgId) {
throw new LocalError("rooms.waitForMessages requires an orgId", "waiting for room messages");
}
if (!roomKey) {
throw new LocalError("rooms.waitForMessages requires a roomKey", "waiting for room messages");
}
const waitSeconds = Math.min(Math.max(Math.trunc(opts.waitSeconds ?? 25), 1), 25);
const timeoutMs = opts.timeoutMs ?? 120_000;
const fallbackPollMs = Math.max(opts.pollMs ?? 5000, 1000);
const startedAt = Date.now();
// The hold requested on each read is clamped to the budget that is LEFT,
// never the full `waitSeconds`: a held read is the gateway sleeping on
// the caller's behalf, so a 25 s hold issued with 5 s of budget remaining
// would overshoot `timeoutMs` by 20 s — past a coding harness's own
// tool-call limit when the caller sized the timeout to it (design D7).
const remainingMs = () => timeoutMs - (Date.now() - startedAt);
const holdFor = () => Math.max(1, Math.min(waitSeconds, Math.floor(remainingMs() / 1000)));
const fetchPage = (wait) => this.listMessages(orgId, roomKey, {
...(opts.cursor !== undefined ? { cursor: opts.cursor } : {}),
...(opts.threadId !== undefined ? { threadId: opts.threadId } : {}),
...(opts.addressedTo !== undefined ? { addressedTo: opts.addressedTo } : {}),
...(opts.presenceId !== undefined ? { presenceId: opts.presenceId } : {}),
...(opts.sessionKey !== undefined ? { sessionKey: opts.sessionKey } : {}),
wait,
});
let page = await fetchPage(holdFor());
opts.onPoll?.(page, Date.now() - startedAt);
while (page.messages.length === 0) {
const elapsed = Date.now() - startedAt;
// Evidence, not version: THIS page decides whether the next read is a
// zero-sleep held re-read (the fetch itself already blocked up to
// `waitSeconds`) or a throttled poll.
const held = typeof page.waited_ms === "number";
const sleepMs = held ? 0 : fallbackPollMs;
// Look ahead by the upcoming sleep, exactly like the shared `waitFor`
// helper's own loop guard — otherwise a budget check made only BEFORE
// each sleep lets the total elapsed time overshoot `timeoutMs` by up
// to one whole `sleepMs`.
if (elapsed + sleepMs >= timeoutMs)
break;
if (sleepMs > 0)
await new Promise((resolve) => setTimeout(resolve, sleepMs));
// Less than a full second of budget left cannot be expressed as a
// hold (the gateway clamps `wait` to >= 1) — stop rather than overshoot.
if (remainingMs() < 1000)
break;
page = await fetchPage(holdFor());
opts.onPoll?.(page, Date.now() - startedAt);
}
let livePresences = Array.isArray(page.live_presences) ? page.live_presences : [];
if (!Array.isArray(page.live_presences)) {
try {
const listed = await this.listPresences(orgId, roomKey);
livePresences = opts.presenceId ? listed.presences.filter((p) => p.presence_id !== opts.presenceId) : listed.presences;
}
catch {
livePresences = [];
}
}
return {
...page,
settled: page.messages.length > 0,
waited_ms: Date.now() - startedAt,
live_presences: livePresences,
};
}
/**
* Read one message with its full body and per-recipient ack state
* (`GET /orgs/v1/:org_id/rooms/:room_key/messages/:message_id`).
*/
async getMessage(orgId, roomKey, messageId) {
if (!orgId) {
throw new LocalError("rooms.getMessage requires an orgId", "reading room message");
}
if (!roomKey) {
throw new LocalError("rooms.getMessage requires a roomKey", "reading room message");
}
if (!messageId) {
throw new LocalError("rooms.getMessage requires a messageId", "reading room message");
}
return this.client.request(`${roomPath(orgId, roomKey)}/messages/${encodeURIComponent(messageId)}`, { method: "GET", context: "reading room message" });
}
/**
* Acknowledge a message addressed to you
* (`POST /orgs/v1/:org_id/rooms/:room_key/messages/:message_id/ack`).
* Recipients only (422 otherwise). Idempotent — a replay reports the
* ORIGINAL `acked_at` with `already_acked: true`.
*/
async ackMessage(orgId, roomKey, messageId, opts = {}) {
if (!orgId) {
throw new LocalError("rooms.ackMessage requires an orgId", "acknowledging room message");
}
if (!roomKey) {
throw new LocalError("rooms.ackMessage requires a roomKey", "acknowledging room message");
}
if (!messageId) {
throw new LocalError("rooms.ackMessage requires a messageId", "acknowledging room message");
}
const body = {};
if (opts.presenceId !== undefined)
body.presence_id = opts.presenceId;
if (opts.sessionKey !== undefined)
body.session_key = opts.sessionKey;
return this.client.request(`${roomPath(orgId, roomKey)}/messages/${encodeURIComponent(messageId)}/ack`, { method: "POST", body, context: "acknowledging room message" });
}
/**
* Create an advisory claim
* (`POST /orgs/v1/:org_id/rooms/:room_key/claims`). GRANT-AND-REPORT: the
* 201 always succeeds and returns the complete `conflicts[]` — a claim
* never blocks anything, anywhere (deploys included). Auto-expires
* (`ttlSeconds` default 3600, max 86400); at most 32 active claims per
* presence. Omitting `presenceId` registers a fresh session presence for the
* claim — pass your stored `presenceId` to attribute it to this session.
*/
async createClaim(orgId, roomKey, input) {
if (!orgId) {
throw new LocalError("rooms.createClaim requires an orgId", "creating room claim");
}
if (!roomKey) {
throw new LocalError("rooms.createClaim requires a roomKey", "creating room claim");
}
if (!input?.resource) {
throw new LocalError("rooms.createClaim requires { resource }", "creating room claim");
}
if (!input?.mode) {
throw new LocalError("rooms.createClaim requires { mode }", "creating room claim");
}
const body = { resource: input.resource, mode: input.mode };
if (input.ttlSeconds !== undefined)
body.ttl_seconds = input.ttlSeconds;
if (input.note !== undefined)
body.note = input.note;
if (input.presenceId !== undefined)
body.presence_id = input.presenceId;
if (input.sessionKey !== undefined)
body.session_key = input.sessionKey;
return this.client.request(`${roomPath(orgId, roomKey)}/claims`, {
method: "POST",
body,
context: "creating room claim",
});
}
/**
* List a room's active claims
* (`GET /orgs/v1/:org_id/rooms/:room_key/claims`).
* `opts.includeInactive` adds released/expired history (with
* `released_at`).
*/
async listClaims(orgId, roomKey, opts = {}) {
if (!orgId) {
throw new LocalError("rooms.listClaims requires an orgId", "listing room claims");
}
if (!roomKey) {
throw new LocalError("rooms.listClaims requires a roomKey", "listing room claims");
}
return this.client.request(`${roomPath(orgId, roomKey)}/claims${claimsQuery(opts)}`, { method: "GET", context: "listing room claims" });
}
/**
* Release your own claim
* (`DELETE /orgs/v1/:org_id/rooms/:room_key/claims/:claim_id`). Holder's
* credential only. Idempotent — an already-released claim returns
* `already_released: true` with the original time.
*/
async releaseClaim(orgId, roomKey, claimId) {
if (!orgId) {
throw new LocalError("rooms.releaseClaim requires an orgId", "releasing room claim");
}
if (!roomKey) {
throw new LocalError("rooms.releaseClaim requires a roomKey", "releasing room claim");
}
if (!claimId) {
throw new LocalError("rooms.releaseClaim requires a claimId", "releasing room claim");
}
return this.client.request(`${roomPath(orgId, roomKey)}/claims/${encodeURIComponent(claimId)}`, { method: "DELETE", context: "releasing room claim" });
}
// ── Room Invite — mint / join (add-room-invite design D1-D11) ────────────
/**
* Mint a Room Invite Key from the room the caller stands in
* (`POST /orgs/v1/:org_id/rooms/:room_key/invites`) — a single-use bearer
* key (`kri1_…`) whose redeemer becomes a permanent `viewer` of the org,
* the narrowest membership that can message (design D4: never `--role`,
* never wider, never auto-admitted as a vault writer). Requires
* `developer`+ (session, wallet, or admin credential — a grant key is
* refused, since a room invite confers org membership).
*
* `invite_id` and `master_secret` are generated LOCALLY (design D3): the
* gateway never sees `master_secret`, only the SHA-256 `auth_hash` this
* call derives and sends. The assembled key is returned exactly ONCE —
* nothing here or downstream persists it.
*/
async invite(orgId, roomKey, opts = {}) {
gateSecret(this.client, "rooms.invite", orgId, roomKey, opts);
if (!orgId) {
throw new LocalError("rooms.invite requires an orgId", "minting a room invite");
}
if (!roomKey) {
throw new LocalError("rooms.invite requires a roomKey", "minting a room invite");
}
const { assembleRoomInviteKey, deriveRoomInviteAuthSecret, computeRoomInviteAuthHash, randomRedeemId } = await nodeOnlyBearerRedeemKey("invite");
const inviteId = randomRedeemId();
const { key, invite_id_bytes, master_secret } = assembleRoomInviteKey(inviteId);
const authHash = computeRoomInviteAuthHash(deriveRoomInviteAuthSecret(invite_id_bytes, master_secret));
const body = { invite_id: inviteId, auth_hash: authHash };
if (opts.note !== undefined)
body.note = opts.note;
if (opts.inviterPresenceId !== undefined)
body.inviter_presence_id = opts.inviterPresenceId;
if (opts.expiresInSeconds !== undefined)
body.expires_in_seconds = opts.expiresInSeconds;
const result = await this.client.request(`/orgs/v1/${encodeURIComponent(orgId)}/rooms/${encodeURIComponent(roomKey)}/invites`, {
method: "POST",
body,
context: "minting a room invite",
});
if (result.invite_id !== inviteId) {
// Mirrors vault's own `INVITE_ID_MISMATCH` fail-fast (design D3):
// the printed key is assembled from the id THIS call generated, so a
// gateway that echoed a different one would hand out a key that does
// not match the stored row.
throw new LocalError(`the gateway minted a different invite_id (${result.invite_id}) than requested (${inviteId}) — the assembled key would not match; retry`, "minting a room invite", { code: "ROOM_INVITE_ID_MISMATCH", details: { requested: inviteId, minted: result.invite_id } });
}
return {
key,
invite_id: result.invite_id,
kind: result.kind,
role: result.role,
room: result.room,
expires_at: result.expires_at,
warning: result.warning,
warnings: result.warnings,
...(result.next_actions !== undefined ? { next_actions: result.next_actions } : {}),
};
}
/**
* Claim a Room Invite Key (`POST /rooms/v1/invites/:invite_id/redeem`) —
* parses the key CLIENT-SIDE first, refusing a `kgh1_`/`kgi1_` vault key
* BY NAME (pointing at `run402 repos resume`/`run402 repos join`) before
* any network call (design D3). The redemption is an x402-PAID resource (the
* `room_seat` SKU, testnet only): the VERIFIED PAYER of that payment
* becomes the redeemer, so this call is sent through the client's paid
* fetch WITHOUT a bearer credential (`withAuth: false`) — no
* `SIGN-IN-WITH-X` header, and any cached control-plane session is
* deliberately not attached, exactly matching the gateway's own
* `403 ROOM_INVITE_REDEEM_REQUIRES_WALLET` refusal for a bearer-credentialed
* request at this route. A same-payer replay never pays twice
* (`deduplicated: true`, no second charge).
*/
async join(key) {
gateSecret(this.client, "rooms.join", key);
if (!key) {
throw new LocalError("rooms.join requires a key", "redeeming a room invite");
}
const { parseRoomInviteKey, deriveRoomInviteAuthSecret } = await nodeOnlyBearerRedeemKey("join");
const parsed = parseRoomInviteKey(key); // throws ROOM_INVITE_KEY_WRONG_KIND/_KEY_INVALID synchronously — never contacts the gateway on a bad key
const authSecret = deriveRoomInviteAuthSecret(parsed.invite_id_bytes, parsed.master_secret);
return this.client.request(`/rooms/v1/invites/${encodeURIComponent(parsed.invite_id)}/redeem`, {
method: "POST",
// Base64url — the gateway decodes base64/base64url and substitutes 32
// zero bytes for anything else, so a malformed value never matches
// any stored hash rather than raising a distinguishing error.
body: { auth_secret: toBase64url(authSecret) },
withAuth: false,
context: "redeeming a room invite",
});
}
/**
* Return a room-scoped sub-client with `(orgId, roomKey)` pre-bound.
* Synchronous — both ids are explicit. For a project's default room
* without knowing the org, use {@link forProject}.
*/
scoped(orgId, roomKey) {
return new ScopedRoom(this, orgId, roomKey);
}
/**
* Resolve a project's DEFAULT room (`GET /projects/v1/:project_id` for the
* owning `org_id`, then {@link scoped}). The default room's key IS the
* project id verbatim — this is the zero-config rendezvous: two agents
* holding the same `run402.config.json` land in the same room with no
* shared setup.
*/
async forProject(projectId) {
if (!projectId) {
throw new LocalError("rooms.forProject requires a projectId", "resolving a project's default room");
}
const project = await this.client.request(`/projects/v1/${encodeURIComponent(projectId)}`, { method: "GET", context: "resolving a project's default room" });
const orgId = typeof project.org_id === "string" ? project.org_id : "";
if (!orgId) {
throw new LocalError("rooms.forProject could not resolve the project's owning org: the project read carried no org_id", "resolving a project's default room");
}
return this.scoped(orgId, projectId);
}
}
/**
* Terminal room-invite redeem refusals the gateway's own x402 paywall NEVER
* settles for (design D5 — the paywall buffers and settles only on a
* sub-400 response, so any of these five codes means no payment ever
* completed, refunded or otherwise). Live-proof defect B: without this, a
* spent/expired/revoked key, or a bearer credential presented to the redeem route
* route, surfaced as a generic `X402_PAYMENT_OUTCOME_AMBIGUOUS` — alarming
* and wrong, since the gateway had already answered with one of these and
* moved no funds. `node/paid-fetch.ts`'s default paid fetch recognizes a
* response carrying one of these codes, FROM this SDK's own configured API
* origin, as `"failed"` rather than `"ambiguous"` — letting the gateway's
* own typed envelope (not a synthesized payment-attempt error) reach the
* caller. Scoped by CODE, never by route: an arbitrary third-party paid URL
* — even one that echoes one of these exact strings — is never treated as
* this SDK's own origin, so its non-2xx after dispatch stays genuinely
* ambiguous, unchanged.
*/
export const ROOM_INVITE_TERMINAL_REFUSAL_CODES = new Set([
"ROOM_INVITE_KEY_INVALID",
"ROOM_INVITE_KEY_EXPIRED",
"ROOM_INVITE_KEY_REVOKED",
"ROOM_INVITE_KEY_ALREADY_REDEEMED",
"ROOM_INVITE_REDEEM_REQUIRES_WALLET",
]);
/** True when `envelope.code` is one of {@link ROOM_INVITE_TERMINAL_REFUSAL_CODES}. */
export function isTerminalRoomInviteRefusal(envelope) {
const code = envelope?.code;
return typeof code === "string" && ROOM_INVITE_TERMINAL_REFUSAL_CODES.has(code);
}
/**
* A room-scoped sub-client returned by {@link Rooms.scoped} /
* {@link Rooms.forProject}. The `(orgId, roomKey)` pair is bound at
* construction; instance operations drop both leading arguments.
*/
export class ScopedRoom {
rooms;
/** The org id this sub-client is bound to. Read-only. */
orgId;
/** The room key this sub-client is bound to (a project id for a default room). Read-only. */
roomKey;
constructor(rooms, orgId, roomKey) {
this.rooms = rooms;
if (!orgId) {
throw new LocalError("rooms.scoped requires an orgId", "scoping client to room");
}
if (!roomKey) {
throw new LocalError("rooms.scoped requires a roomKey", "scoping client to room");
}
this.orgId = orgId;
this.roomKey = roomKey;
}
/** See {@link Rooms.get}. */
get() {
return this.rooms.get(this.orgId, this.roomKey);
}
/** See {@link Rooms.leave}. */
leave(presenceId) {
return this.rooms.leave(this.orgId, this.roomKey, presenceId);
}
/** See {@link Rooms.registerPresence}. */
registerPresence(opts = {}) {
return this.rooms.registerPresence(this.orgId, this.roomKey, opts);
}
/** See {@link Rooms.listPresences}. */
listPresences(opts = {}) {
return this.rooms.listPresences(this.orgId, this.roomKey, opts);
}
/** See {@link Rooms.getPresence}. */
getPresence(presenceId) {
return this.rooms.getPresence(this.orgId, this.roomKey, presenceId);
}
/** See {@link Rooms.sendMessage}. */
sendMessage(input) {
return this.rooms.sendMessage(this.orgId, this.roomKey, input);
}
/** See {@link Rooms.listMessages}. */
listMessages(opts = {}) {
return this.rooms.listMessages(this.orgId, this.roomKey, opts);
}
/** See {@link Rooms.getMessage}. */
getMessage(messageId) {
return this.rooms.getMessage(this.orgId, this.roomKey, messageId);
}
/** See {@link Rooms.waitForMessages}. */
waitForMessages(opts = {}) {
return this.rooms.waitForMessages(this.orgId, this.roomKey, opts);
}
/** See {@link Rooms.ackMessage}. */
ackMessage(messageId, opts = {}) {
return this.rooms.ackMessage(this.orgId, this.roomKey, messageId, opts);
}
/** See {@link Rooms.createClaim}. */
createClaim(input) {
return this.rooms.createClaim(this.orgId, this.roomKey, input);
}
/** See {@link Rooms.listClaims}. */
listClaims(opts = {}) {
return this.rooms.listClaims(this.orgId, this.roomKey, opts);
}
/** See {@link Rooms.releaseClaim}. */
releaseClaim(claimId) {
return this.rooms.releaseClaim(this.orgId, this.roomKey, claimId);
}
/** See {@link Rooms.invite} — pre-bound to this room. */
invite(opts = {}) {
return this.rooms.invite(this.orgId, this.roomKey, opts);
}
}
//# sourceMappingURL=rooms.js.map