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.
879 lines • 39 kB
JavaScript
/**
* Error hierarchy for the Run402 SDK. Every failure throws a subclass of
* {@link Run402Error}. Consumers (MCP handlers, CLI commands, user functions)
* translate these into their native error shapes at the edge.
*
* Branch on {@link Run402Error.kind} (or the exported `is*` type guards) rather
* than `instanceof`. Discriminator-based checks survive duplicate SDK installs,
* bundler chunk splits, ESM/CJS interop, and V8-isolate realm boundaries —
* any setting where a class object's identity might differ from the consumer's
* own class object reference. `instanceof X` continues to work for callers
* holding a single SDK copy (back-compat); the guards are the recommended path.
*/
export class Run402Error extends Error {
/**
* Structural brand. Always `true` on any {@link Run402Error} subclass
* instance, regardless of which SDK copy created it. The exported
* {@link isRun402Error} guard checks this field instead of `instanceof`,
* so cross-realm and cross-bundle errors still match.
*/
isRun402Error = true;
/** HTTP status, or null for local/network failures that produced no response. */
status;
/**
* Parsed response body, or null when no body was received. Holds the raw
* gateway envelope, so additive fields not lifted onto typed properties stay
* reachable here — notably `correlated_platform_incident`
* (`{ id, subsystem, status: "ongoing" | "resolved" }`), present ONLY while
* an OPEN platform incident correlates with this error's `code` (a `poll`
* action also rides in {@link nextActions}). It is a CORRELATION, not an
* exoneration: the platform states it was degraded when the call failed and
* leaves the judgment to you. Treat it as a strong signal to poll the events
* feed (`r.events.list`) before debugging your own code; the follow-up
* `platform_incident` feed event carries the project's real failed-invocation
* count once the incident resolves.
*/
body;
/** Short verb phrase identifying the attempted operation (e.g. "provisioning project"). */
context;
/** Canonical machine-readable Run402 error code, when the gateway provided one. */
code;
/** High-level error category, e.g. lifecycle, deploy, auth. */
category;
/** Whether the same request may succeed later. */
retryable;
/** Whether repeating the same request should avoid duplicating/corrupting a mutation. */
safeToRetry;
/** Gateway-known mutation progress for failed mutating operations. */
mutationState;
/** Trace id suitable for support/debugging. */
traceId;
/** Canonical structured context. Preserved by reference from the response body. */
details;
/** Advisory next actions (gateway-authored or SDK-synthesized). Rendering them must not execute them. */
nextActions;
/**
* Quota-denial scope (v1.46+). `"organization"` for pooled organization
* denials; `"project"` for the orphan fallback (project whose organization
* row was purged but cascade has not yet run). Lifted from
* `details.scope` when the gateway returned it. Undefined for errors
* that are not quota-related.
*/
quotaScope;
constructor(message, status, body, context) {
super(message);
this.name = this.constructor.name;
this.status = status;
this.body = body;
this.context = context;
const envelope = canonicalEnvelope(body);
const ctor = this.constructor;
const envelopeCode = typeof envelope?.code === "string" ? envelope.code : undefined;
const envelopeCategory = typeof envelope?.category === "string" ? envelope.category : undefined;
const envelopeRetryable = typeof envelope?.retryable === "boolean" ? envelope.retryable : undefined;
if (envelopeCode !== undefined)
this.code = envelopeCode;
else if (ctor.DEFAULT_CODE !== undefined)
this.code = ctor.DEFAULT_CODE;
if (envelopeCategory !== undefined)
this.category = envelopeCategory;
else if (ctor.DEFAULT_CATEGORY !== undefined)
this.category = ctor.DEFAULT_CATEGORY;
if (envelopeRetryable !== undefined)
this.retryable = envelopeRetryable;
else if (ctor.DEFAULT_RETRYABLE !== undefined)
this.retryable = ctor.DEFAULT_RETRYABLE;
if (typeof envelope?.safe_to_retry === "boolean")
this.safeToRetry = envelope.safe_to_retry;
if (typeof envelope?.mutation_state === "string")
this.mutationState = envelope.mutation_state;
if (typeof envelope?.trace_id === "string")
this.traceId = envelope.trace_id;
if (envelope && Object.prototype.hasOwnProperty.call(envelope, "details")) {
this.details = envelope.details;
}
if (Array.isArray(envelope?.next_actions) && envelope.next_actions.length > 0) {
this.nextActions = envelope.next_actions;
}
else {
// Gateway gap: `POST /apply/v1/plans` 401 and some
// validation 400s return `next_actions: []`. Synthesize the canonical
// action for known codes so the relay never hands an agent an empty array.
// Only fills gaps — never overrides gateway-authored actions.
const synthesized = synthesizeNextActions(this.code);
if (synthesized.length > 0)
this.nextActions = synthesized;
}
const scope = extractQuotaScope(envelope);
if (scope !== undefined)
this.quotaScope = scope;
}
/**
* Canonical structured envelope for `JSON.stringify`. Without this, an
* `Error` instance serializes as `"{}"` (its built-in fields are
* non-enumerable), losing every structured detail an agent needs for
* triage. Subclasses with extra fields (e.g. {@link Run402DeployError})
* override and spread `super.toJSON()`.
*/
toJSON() {
return {
name: this.name,
kind: this.kind,
message: this.message,
status: this.status,
code: this.code,
category: this.category,
retryable: this.retryable,
safeToRetry: this.safeToRetry,
mutationState: this.mutationState,
traceId: this.traceId,
context: this.context,
details: this.details,
nextActions: this.nextActions,
quotaScope: this.quotaScope,
body: this.body,
};
}
}
function canonicalEnvelope(body) {
return body && typeof body === "object" && !Array.isArray(body)
? body
: null;
}
function extractQuotaScope(envelope) {
if (!envelope)
return undefined;
const details = envelope.details;
if (!details || typeof details !== "object" || Array.isArray(details))
return undefined;
const scope = details.scope;
return scope === "organization" || scope === "project" ? scope : undefined;
}
/**
* HTTP 402 — a genuine protocol payment challenge (x402 or a successor
* rail): insufficient balance, or a priced quote that must be paid before
* the request can proceed. style.md reserves 402 for exactly this; quota,
* lifecycle (frozen/dormant lease expiry), and budget-cap denials return
* 403 instead with their own canonical `code` (see the api-error-envelope
* `HTTP 402 is reserved for protocol payment challenges` requirement).
*/
export class PaymentRequired extends Run402Error {
static DEFAULT_CODE = "PAYMENT_REQUIRED";
static DEFAULT_CATEGORY = "payment_required";
static DEFAULT_RETRYABLE = false;
kind = "payment_required";
}
/** Project ID is not present in the credential provider (local miss) or the gateway returned 404. */
export class ProjectNotFound extends Run402Error {
static DEFAULT_CODE = "PROJECT_NOT_FOUND";
static DEFAULT_CATEGORY = "not_found";
static DEFAULT_RETRYABLE = false;
kind = "project_not_found";
projectId;
constructor(projectId, context, status = null, body = null) {
super(`Project ${projectId} not found`, status, body, context);
this.projectId = projectId;
}
}
/**
* HTTP 401 or 403 — the generic denial bucket: authentication missing,
* invalid, or insufficient for the operation. Also the fallback for any
* other 403 that isn't one of the more specific subclasses below (e.g.
* `NOT_AUTHORIZED`, `STEP_UP_REQUIRED`) — since 402 is reserved for
* genuine payment challenges, this now also covers non-payment 403 denials
* such as quota (`QUOTA_EXCEEDED`), lifecycle (`PROJECT_FROZEN` /
* `PROJECT_DORMANT`), and grant-key spend-cap denials. Check `body.code` (or
* use `formatCanonicalErrorContext`-style parsing) to distinguish these
* from a true auth failure.
*/
export class Unauthorized extends Run402Error {
static DEFAULT_CODE = "UNAUTHORIZED";
static DEFAULT_CATEGORY = "auth";
static DEFAULT_RETRYABLE = false;
kind = "unauthorized";
}
/**
* HTTP 403 `NOT_AUTHORIZED` — the org-owned control plane (gateway v1.77+)
* denied a control-plane action. A wallet *authenticates* (SIWX resolves it to
* a principal); *authorization* is an org (organization) membership in the
* role lattice `owner > admin > developer > billing > viewer`, or a per-project
* grant for agent/CI principals — never `wallet_address == signer`. High-stakes
* ops (delete, transfer-of-ownership, membership change) require an active
* `owner` membership.
*
* Distinct from {@link Unauthorized} (authentication missing/invalid): here the
* caller IS authenticated but lacks the required role/capability, so the fix is
* to obtain access (a membership/grant), not to re-authenticate. The gateway
* returns 403 — never 404 — even when the project does not exist, so existence
* is not leaked to a non-authorized caller (surfaced as `reason:
* "project_not_found"`). Branch on `kind === "not_authorized"` (or
* {@link isNotAuthorized}).
*/
export class NotAuthorizedError extends Run402Error {
static DEFAULT_CODE = "NOT_AUTHORIZED";
static DEFAULT_CATEGORY = "auth";
static DEFAULT_RETRYABLE = false;
kind = "not_authorized";
/** The control-plane action that was denied, when the gateway named one. */
action;
/** Org role required for the action (e.g. `"owner"`), or null when the denial is capability-based. */
requiredRole;
/** Per-project capability required (e.g. `"deploy"`), or null when the denial is role-based. */
requiredCapability;
/**
* Why authorization failed. Known values: `"member"` (no active membership),
* `"grant"` (no per-project grant), `"forbidden"` (role/grant too low), and
* `"project_not_found"` (returned as 403 to avoid leaking existence). Future
* strings pass through unchanged.
*/
reason;
constructor(message, status, body, context) {
super(message, status, body, context);
const envelope = body && typeof body === "object" && !Array.isArray(body)
? body
: null;
const details = envelope &&
typeof envelope.details === "object" &&
envelope.details !== null &&
!Array.isArray(envelope.details)
? envelope.details
: null;
this.action = typeof details?.action === "string" ? details.action : null;
this.requiredRole =
typeof details?.required_role === "string" ? details.required_role : null;
this.requiredCapability =
typeof details?.required_capability === "string"
? details.required_capability
: null;
this.reason = typeof details?.reason === "string" ? details.reason : null;
}
toJSON() {
return {
...super.toJSON(),
action: this.action,
requiredRole: this.requiredRole,
requiredCapability: this.requiredCapability,
reason: this.reason,
};
}
}
/** Any other non-2xx HTTP response from the gateway. */
export class ApiError extends Run402Error {
static DEFAULT_CODE = "API_ERROR";
static DEFAULT_CATEGORY = "api";
static DEFAULT_RETRYABLE = false;
kind = "api_error";
}
/** The underlying `fetch` threw before producing a response (DNS, connection reset, offline). */
export class NetworkError extends Run402Error {
static DEFAULT_CODE = "NETWORK_ERROR";
static DEFAULT_CATEGORY = "network";
static DEFAULT_RETRYABLE = true;
kind = "network_error";
cause;
constructor(message, cause, context) {
super(message, null, null, context);
this.cause = cause;
}
}
/**
* Node paid-fetch failure with an explicit payment-effect boundary.
*
* A caller may repeat the request only when `safeToRetry === true`. Once a
* payment-bearing request may have reached the target, the SDK reports an
* ambiguous mutation and directs the caller to reconcile by
* `paymentAttemptId`; it never turns a post-dispatch transport error into a
* blind retry. The raw cause remains available in-process but is deliberately
* omitted from `toJSON()` and the canonical body.
*/
/**
* The underlying failure, CLASSIFIED rather than quoted (`details.failure`). A raw cause message
* can carry a URL with a token, a key path, or a provider's echo of a secret
* (`errors.test.ts` pins that none of that ever serializes), so only the
* error's name, its code, and a recognized reason cross into the envelope.
*/
const CAUSE_REASONS = [
[/maxAmountPerPayment|spendControls/i, "The payment library's own per-payment spend cap refused the amount. run402 sets its caps explicitly; upgrade the run402 CLI/SDK."],
[/Facilitator does not support|unsupported network|no supported payment kinds/i, "No registered signer matches the accepted network or scheme."],
[/user rejected|denied|signature/i, "The signer refused or could not produce the authorization signature."],
];
function summarizeCause(cause) {
if (!cause || typeof cause !== "object")
return null;
const c = cause;
const message = typeof c.message === "string" ? c.message : "";
const reason = CAUSE_REASONS.find(([pattern]) => pattern.test(message))?.[1];
return {
error_name: typeof c.name === "string" ? c.name : "Error",
...(typeof c.code === "string" ? { code: c.code } : {}),
...(reason ? { reason } : {}),
};
}
export class PaymentAttemptError extends Run402Error {
kind = "payment_attempt_error";
code;
phase;
paymentAttemptId;
providerStarted;
responseStatus;
retryable;
safeToRetry;
mutationState;
cause;
constructor(init) {
const retryable = init.retryable ?? init.safeToRetry;
const nextActions = init.nextActions ??
(init.safeToRetry && retryable
? [
{
type: "retry",
payment_attempt_id: init.paymentAttemptId,
why: "No payment-bearing request was dispatched; retrying the original request is safe.",
},
]
: !init.safeToRetry
? [
{
type: "reconcile_payment",
payment_attempt_id: init.paymentAttemptId,
safe_to_auto_execute: false,
why: "A payment-bearing request may have reached the provider. Reconcile this attempt before issuing another payment.",
},
{
type: "poll",
payment_attempt_id: init.paymentAttemptId,
safe_to_auto_execute: true,
why: "Poll the target or payment provider for the final state of this attempt.",
},
]
: []);
const details = {
payment_attempt_id: init.paymentAttemptId,
phase: init.phase,
provider_started: init.providerStarted,
...(init.transportCode ? { transport_code: init.transportCode } : {}),
response_status: init.responseStatus ?? null,
...(init.request ? { request: init.request } : {}),
// The underlying failure, in words. A signing refusal without its reason
// ("could not be created") is undebuggable from a terminal; the cause
// never carries keys, headers, or proofs, only the library's message.
...(summarizeCause(init.cause) ? { failure: summarizeCause(init.cause) } : {}),
};
super(init.message, null, {
error: "payment_attempt_failed",
message: init.message,
code: init.code,
category: init.category ?? "payment",
source: "sdk",
retryable,
safe_to_retry: init.safeToRetry,
mutation_state: init.mutationState,
details,
next_actions: nextActions,
}, "executing x402 paid fetch");
this.code = init.code;
this.phase = init.phase;
this.paymentAttemptId = init.paymentAttemptId;
this.providerStarted = init.providerStarted;
this.responseStatus = init.responseStatus ?? null;
this.retryable = retryable;
this.safeToRetry = init.safeToRetry;
this.mutationState = init.mutationState;
this.cause = init.cause;
}
toJSON() {
return {
...super.toJSON(),
phase: this.phase,
paymentAttemptId: this.paymentAttemptId,
providerStarted: this.providerStarted,
responseStatus: this.responseStatus,
};
}
}
/** Local/filesystem error — input validation, missing path, unreadable dir. No HTTP involved. */
export class LocalError extends Run402Error {
static DEFAULT_CODE = "LOCAL_ERROR";
static DEFAULT_CATEGORY = "local";
static DEFAULT_RETRYABLE = false;
kind = "local_error";
cause;
/** One-line remedy for a person reading the error, when the thrower has one. */
hint;
/**
* @param message Human-readable error message.
* @param context Short verb phrase identifying the operation (used for triage).
* @param opts Optional. Pass a raw `unknown` for back-compat (treated as `cause`),
* or an options bag `{ cause?, code?, hint?, details?, next_actions? }` to thread a stable error
* `code` (mirrors a gateway error code so client-side validators throw with
* the same `code` field consumers branch on).
*/
constructor(message, context, opts) {
const bag = opts && typeof opts === "object" && !Array.isArray(opts) &&
("cause" in opts ||
"code" in opts ||
"hint" in opts ||
"details" in opts ||
"next_actions" in opts)
? opts
: null;
const code = bag?.code;
const hint = bag?.hint;
const details = bag?.details;
const nextActions = bag?.next_actions;
const cause = bag ? bag.cause : opts;
const envelope = code !== undefined || hint !== undefined || details !== undefined || nextActions !== undefined
? {
...(code !== undefined ? { code } : {}),
...(hint !== undefined ? { hint } : {}),
...(details !== undefined ? { details } : {}),
...(nextActions !== undefined ? { next_actions: nextActions } : {}),
}
: null;
super(message, null, envelope, context);
if (cause !== undefined)
this.cause = cause;
if (hint !== undefined)
this.hint = hint;
}
toJSON() {
const json = super.toJSON();
return this.hint === undefined ? json : { ...json, hint: this.hint };
}
}
/** Local credential-cache miss — the project may exist, but no cached keys are available locally. */
export class ProjectCredentialNotFound extends LocalError {
projectId;
constructor(projectId, context, details = {}) {
super(`No local project credentials cached for ${projectId}`, context, {
code: "PROJECT_CREDENTIAL_NOT_FOUND",
details: {
project_id: projectId,
source: "local_cache",
...details,
},
next_actions: [
{
type: "run_command",
command: `run402 credentials project-keys status --project ${projectId}`,
why: "Inspect whether this profile has cached project keys without revealing secrets.",
},
{
type: "run_command",
command: `run402 credentials project-keys import --project ${projectId} --service-key-stdin`,
why: "Import a service key only when this operation is classified as credential-required.",
},
],
});
this.name = "ProjectCredentialNotFound";
this.projectId = projectId;
}
}
/**
* `SECRET_REQUIRES_CLI` — a method that would return or consume a one-time
* secret (a grant key, a Handoff or Invite Key, project credentials, a
* private key, a Lightning pairing) was called on a client whose
* `capabilities.returnSecrets` is false: the `sandbox` surface an MCP `run`
* snippet executes on. Thrown before any request is made, so nothing secret
* ever reaches the snippet or the MCP result cache. Carries exactly one
* `run_cli_command` next action naming the CLI command that performs the
* same operation, where the secret prints once to a person's terminal.
*/
export class SecretRequiresCliError extends LocalError {
/** The exact CLI command line for the same operation. */
command;
constructor(command, why) {
const reason = why ?? "This operation prints a one-time secret, which only the CLI hands to a person.";
super(`This operation returns or consumes a one-time secret and is refused on this client. Run it from the CLI: ${command}`, "checking secret-return capability", {
code: "SECRET_REQUIRES_CLI",
details: { command },
next_actions: [{ type: "run_cli_command", command, why: reason }],
});
this.name = "SecretRequiresCliError";
this.command = command;
}
toJSON() {
return { ...super.toJSON(), command: this.command };
}
}
export const PROJECT_CREDENTIAL_ERROR_CODES = [
"PROJECT_CREDENTIAL_NOT_FOUND",
"PROJECT_CREDENTIAL_INVALID",
"PROJECT_CREDENTIAL_EXPIRED",
"PROJECT_CREDENTIAL_PROJECT_MISMATCH",
];
const PROJECT_CREDENTIAL_ERROR_CODE_SET = new Set(PROJECT_CREDENTIAL_ERROR_CODES);
export class Run402DeployError extends Run402Error {
kind = "deploy_error";
code;
phase;
resource;
retryable;
operationId;
planId;
fix;
logs;
rolledBack;
attempts;
maxRetries;
lastRetryCode;
constructor(message, init) {
super(message, init.status ?? null, init.body ?? null, init.context);
this.code = init.code;
this.phase = init.phase ?? null;
this.resource = init.resource ?? null;
this.retryable = init.retryable ?? false;
this.operationId = init.operationId ?? null;
this.planId = init.planId ?? null;
this.fix = init.fix ?? null;
this.logs = init.logs ?? null;
this.rolledBack = init.rolledBack ?? false;
if (init.attempts !== undefined)
this.attempts = init.attempts;
if (init.maxRetries !== undefined)
this.maxRetries = init.maxRetries;
if (init.lastRetryCode !== undefined)
this.lastRetryCode = init.lastRetryCode;
}
toJSON() {
return {
...super.toJSON(),
phase: this.phase,
resource: this.resource,
operationId: this.operationId,
planId: this.planId,
fix: this.fix,
logs: this.logs,
rolledBack: this.rolledBack,
retryable: this.retryable,
attempts: this.attempts,
maxRetries: this.maxRetries,
lastRetryCode: this.lastRetryCode,
};
}
}
/**
* Project has a pending transfer (v1.59+). Gateway returns 409 with
* `code: "PROJECT_HAS_PENDING_TRANSFER"` from the transfer-freeze middleware
* mounted on owner-side mutations (deploy, secrets, custom domains, function
* CRUD, scheduled-function changes, mailbox config, CI bindings, project
* rename, etc.). The pending transfer must be accepted, cancelled, or
* allowed to expire (72h) before owner-side mutations resume.
*
* The error carries `transferId` (when the gateway resolved it) and
* `cancelPath` lifted from `next_actions[].path`, so callers can present an
* actionable resolution path. `previewPath` mirrors the view-transfer
* next_action when present.
*/
export class TransferFreezeError extends Run402Error {
static DEFAULT_CODE = "PROJECT_HAS_PENDING_TRANSFER";
static DEFAULT_CATEGORY = "validation";
static DEFAULT_RETRYABLE = false;
kind = "transfer_freeze";
/** The pending transfer id when the gateway resolved one. */
transferId;
/** API path to cancel the pending transfer (e.g. `/agent/v1/transfers/<transfer_id>/cancel`). */
cancelPath;
/** API path to view the pending transfer preview. */
previewPath;
projectId;
constructor(message, status, body, context) {
super(message, status, body, context);
const envelope = body && typeof body === "object" && !Array.isArray(body)
? body
: null;
const details = envelope && typeof envelope.details === "object" && envelope.details !== null
? envelope.details
: null;
this.transferId = typeof details?.transfer_id === "string" ? details.transfer_id : null;
this.projectId = typeof details?.project_id === "string" ? details.project_id : null;
const actions = Array.isArray(envelope?.next_actions) ? envelope?.next_actions : [];
this.cancelPath = pickNextActionPath(actions, "cancel_transfer");
this.previewPath = pickNextActionPath(actions, "view_transfer");
}
}
/**
* Synthesize a canonical next action when the gateway returned a known error
* code with an empty/absent `next_actions[]`. Mirrors the WRITE_APPROVAL_*
* synthesis on {@link WriteApprovalRequiredError}; only fills gaps, never overrides.
*/
function synthesizeNextActions(code) {
switch (code) {
case "AUTH_REQUIRED":
return [
{
type: "authenticate",
auth: "SIWX",
why: "Provide SIWX wallet auth (or a sign-in session or grant-key bearer) and retry the request.",
},
];
default:
return [];
}
}
function pickNextActionPath(actions, type) {
for (const a of actions) {
if (a && typeof a === "object" && !Array.isArray(a)) {
const obj = a;
if (obj.type === type && typeof obj.path === "string")
return obj.path;
}
}
return null;
}
/**
* HTTP 403 `STEP_UP_REQUIRED` — the gateway requires a fresh, same-client
* step-up (a recent `passkey` AMR) before this high-stakes control-plane
* operation (delete / transfer / membership / invite / payment drain·rotate)
* may proceed. A `device`-grade session and a write approval can never satisfy
* it; the caller must complete the challenge at {@link challengeUrl} (e.g. by
* signing in again with `run402 login`) on the same client and retry.
*
* Typed fields are lifted from the gateway `details` envelope; the same
* remediation pointer is also present in {@link Run402Error.nextActions} as an
* `authenticate` action.
*/
export class StepUpRequiredError extends Run402Error {
static DEFAULT_CODE = "STEP_UP_REQUIRED";
static DEFAULT_CATEGORY = "auth";
static DEFAULT_RETRYABLE = false;
kind = "step_up_required";
/** AMRs that would satisfy the step-up (e.g. `["passkey"]`). Empty when the gateway omitted it. */
requiredAmr;
/** Max age in seconds the satisfying auth may be; null when the gateway omitted it. */
maxAgeSeconds;
/** Where to run the step-up challenge; null when the gateway omitted it. */
challengeUrl;
/** Why the step-up was demanded (e.g. `"device_flow_forbidden"`); null when absent. */
reason;
constructor(message, status, body, context) {
super(message, status, body, context);
const envelope = body && typeof body === "object" && !Array.isArray(body)
? body
: null;
const details = envelope &&
typeof envelope.details === "object" &&
envelope.details !== null &&
!Array.isArray(envelope.details)
? envelope.details
: null;
this.requiredAmr = Array.isArray(details?.required_amr)
? details.required_amr.filter((x) => typeof x === "string")
: [];
this.maxAgeSeconds =
typeof details?.max_age_seconds === "number" ? details.max_age_seconds : null;
this.challengeUrl =
typeof details?.challenge_url === "string" ? details.challenge_url : null;
this.reason = typeof details?.reason === "string" ? details.reason : null;
}
toJSON() {
return {
...super.toJSON(),
requiredAmr: this.requiredAmr,
maxAgeSeconds: this.maxAgeSeconds,
challengeUrl: this.challengeUrl,
reason: this.reason,
};
}
}
/**
* A signed-in person's command-line write (no wallet) needs a passkey-signed
* **write approval** scoped to a specific `(capability, target)`. Maps the
* gateway codes `WRITE_APPROVAL_REQUIRED` (none attached),
* `WRITE_APPROVAL_SCOPE_MISMATCH` (the attached approval covers a different
* action or target), `WRITE_APPROVAL_BINDING_MISMATCH` (bound to another sign-in
* session), and `WRITE_APPROVAL_SESSION_INVALID` (expired or revoked).
*
* The SDK synthesizes a fully-resolved remediation from the failing request's
* capability + target: read {@link approveCommand} (e.g. `run402 approve
* --action project.deploy --project prj_x`) or the structured
* {@link nextActions}. The SIWX wallet path never triggers this. A write
* approval never satisfies step-up. Branch on
* `kind === "write_approval_required"` (or {@link isWriteApprovalRequired}).
*/
export class WriteApprovalRequiredError extends Run402Error {
static DEFAULT_CODE = "WRITE_APPROVAL_REQUIRED";
static DEFAULT_CATEGORY = "auth";
static DEFAULT_RETRYABLE = false;
kind = "write_approval_required";
/** The gateway write capability needing approval, when known from the request. */
capability;
/** The capability's target (`{ org_id }` or `{ project_id }`), when known. */
target;
/** Fully-resolved CLI command that mints the missing approval, or null if unresolvable. */
approveCommand;
constructor(message, status, body, context, meta) {
super(message, status, body, context);
this.capability = meta?.capability ?? null;
this.target = meta?.target ?? null;
this.approveCommand = buildApproveCommand(this.capability, this.target);
// The gateway WRITE_APPROVAL_* envelope carries no next_actions — synthesize
// a structured one so generic consumers get the remediation too.
if ((!this.nextActions || this.nextActions.length === 0) && this.approveCommand) {
this.nextActions = [
{ type: "approve_write", command: this.approveCommand, why: approveWhy(this.code) },
];
}
}
toJSON() {
return {
...super.toJSON(),
capability: this.capability,
target: this.target,
approveCommand: this.approveCommand,
};
}
}
function buildApproveCommand(capability, target) {
if (!capability)
return null;
if (target?.org_id)
return `run402 approve --action ${capability} --org ${target.org_id}`;
if (target?.project_id)
return `run402 approve --action ${capability} --project ${target.project_id}`;
return `run402 approve --action ${capability}`;
}
function approveWhy(code) {
if (code === "WRITE_APPROVAL_SCOPE_MISMATCH" || code === "WRITE_APPROVAL_BINDING_MISMATCH")
return "The attached write approval covers a different action, target, or sign-in session. Approve this exact target.";
if (code === "WRITE_APPROVAL_SESSION_INVALID")
return "The write approval expired or was revoked. Approve again.";
return "This write needs a passkey write approval.";
}
// ─── Type guards ─────────────────────────────────────────────────────────────
//
// Identity-free guards. Each one checks the structural brand and (for subclass
// guards) the `kind` discriminator. Use these instead of `instanceof X` so the
// check survives duplicate SDK installs, bundler chunk splits, ESM/CJS interop,
// and V8-isolate realm boundaries — anywhere the consumer's class object
// reference might differ from the throw site's.
/** True if `e` is any {@link Run402Error} subclass instance, regardless of which SDK copy created it. */
export function isRun402Error(e) {
return Boolean(e &&
typeof e === "object" &&
e.isRun402Error === true);
}
/** True if `e` is a {@link PaymentRequired}. Survives duplicate SDK copies and realms. */
export function isPaymentRequired(e) {
return isRun402Error(e) && e.kind === "payment_required";
}
/** True if `e` is a {@link ProjectNotFound}. */
export function isProjectNotFound(e) {
return isRun402Error(e) && e.kind === "project_not_found";
}
/** True if `e` is a local project credential-cache miss. */
export function isProjectCredentialNotFound(e) {
return isLocalError(e) && e.code === "PROJECT_CREDENTIAL_NOT_FOUND";
}
/** True if `e` carries any stable project-credential error code. */
export function isProjectCredentialError(e) {
return isRun402Error(e) && typeof e.code === "string" && PROJECT_CREDENTIAL_ERROR_CODE_SET.has(e.code);
}
/** True if cached or supplied project credentials were rejected as invalid. */
export function isProjectCredentialInvalid(e) {
return isRun402Error(e) && e.code === "PROJECT_CREDENTIAL_INVALID";
}
/** True if cached or supplied project credentials were rejected as expired. */
export function isProjectCredentialExpired(e) {
return isRun402Error(e) && e.code === "PROJECT_CREDENTIAL_EXPIRED";
}
/** True if service-key auth was supplied for a different explicit project id. */
export function isProjectCredentialProjectMismatch(e) {
return isRun402Error(e) && e.code === "PROJECT_CREDENTIAL_PROJECT_MISMATCH";
}
/** True if `e` is an {@link Unauthorized}. */
export function isUnauthorized(e) {
return isRun402Error(e) && e.kind === "unauthorized";
}
/** True if `e` is a {@link NotAuthorizedError} (org-owned control-plane denial, gateway v1.77+). */
export function isNotAuthorized(e) {
return isRun402Error(e) && e.kind === "not_authorized";
}
/** True if `e` is an {@link ApiError}. */
export function isApiError(e) {
return isRun402Error(e) && e.kind === "api_error";
}
/** True if `e` is a {@link NetworkError}. */
export function isNetworkError(e) {
return isRun402Error(e) && e.kind === "network_error";
}
/** True if `e` is a phase-aware automatic x402 payment failure. */
export function isPaymentAttemptError(e) {
return isRun402Error(e) && e.kind === "payment_attempt_error";
}
/** True if `e` is a {@link LocalError}. */
export function isLocalError(e) {
return isRun402Error(e) && e.kind === "local_error";
}
/** True if `e` is a {@link Run402DeployError}. */
/** True for the local refusal a `sandbox` client throws instead of returning a one-time secret. */
export function isSecretRequiresCli(e) {
return isLocalError(e) && e.code === "SECRET_REQUIRES_CLI";
}
export function isDeployError(e) {
return isRun402Error(e) && e.kind === "deploy_error";
}
/** True if `e` is a {@link TransferFreezeError}. */
export function isTransferFreezeError(e) {
return isRun402Error(e) && e.kind === "transfer_freeze";
}
/** True if `e` is a {@link StepUpRequiredError}. Survives duplicate SDK copies and realms. */
export function isStepUpRequired(e) {
return isRun402Error(e) && e.kind === "step_up_required";
}
/** True if `e` is a {@link WriteApprovalRequiredError} (a wallet-less write needs a passkey write approval). */
export function isWriteApprovalRequired(e) {
return isRun402Error(e) && e.kind === "write_approval_required";
}
/**
* Extract the v1.46+ quota-denial scope from an error. Returns `"organization"`
* for pooled denials, `"project"` for the orphan fallback, or `undefined`
* when the error is not quota-related (or originated from a pre-v1.46
* gateway that did not set `details.scope`). Safe to call with `unknown`.
*/
export function getQuotaScope(e) {
return isRun402Error(e) ? e.quotaScope : undefined;
}
/**
* Canonical "should I retry this?" policy. Returns true when `e` is a
* {@link Run402Error} AND any of:
* - `e.retryable === true` (gateway flagged it)
* - `e.kind === "network_error"` (fetch never produced a response)
* - `e.status` is 408 (Request Timeout), 425 (Too Early), or 429 (Too Many
* Requests)
* - `e.status` is a 5xx server error
*
* `safeToRetry` is deliberately NOT sufficient by itself: it means a repeated
* mutation should not duplicate/corrupt state, not that the request can succeed
* without a lifecycle/payment/auth action first.
*
* Returns false for non-Run402 errors so it can be safely called with
* `unknown` from a catch block. Used as the default `retryIf` in
* {@link withRetry}.
*/
export function isRetryableRun402Error(e) {
if (!isRun402Error(e))
return false;
if (e.retryable === true)
return true;
if (e.kind === "deploy_error" && e.retryable === false)
return false;
if (hasExplicitRetryableFalse(e.body))
return false;
if (e.kind === "network_error")
return true;
const s = e.status;
if (s === 408 || s === 425 || s === 429)
return true;
if (typeof s === "number" && s >= 500)
return true;
return false;
}
function hasExplicitRetryableFalse(body) {
return Boolean(body &&
typeof body === "object" &&
!Array.isArray(body) &&
Object.prototype.hasOwnProperty.call(body, "retryable") &&
body.retryable === false);
}
//# sourceMappingURL=errors.js.map