> For the complete documentation index, see [llms.txt](https://docs.sigilvault.xyz/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.sigilvault.xyz/typescript/astrotrain-ts.md).

# @sigil/astrotrain

Canonical TypeScript SDK for the SP-facing surface. Mirrors the Rust [`astrotrain`](/rust/astrotrain.md) crate: covers both the pair-handshake endpoints (`POST /sp/pair/*`, `GET /sp/pairings/*/events`, `DELETE /sp/pairings/*`, …) **and** the signing transport (`POST /sp/sign`).

> **Renamed from `@sigil/jazz`.** The old package name still works as a deprecated re-export (with a one-shot console warning) until 0.2.0. Update imports: `from "@sigil/jazz"` → `from "@sigil/astrotrain"`.

The pairing helpers are runtime-agnostic — they need only the WHATWG `fetch` API. The signing client (`AstrotrainClient`, `sign`) handles SP secret material and is **backend-only**: never instantiate it in a browser. The companion [`@sigil/jazz-react`](/typescript/jazz-react.md) package builds the embeddable widget on top of the pairing helpers, with the SP backend acting as a session-mint proxy so the API key stays server-side.

## Install

```bash
pnpm add @sigil/astrotrain
```

## Surface area

### Pair handshake (browser-or-backend through a proxy)

| Function                                    | Purpose                                           |
| ------------------------------------------- | ------------------------------------------------- |
| [`createPairSession`](#createpairsession)   | Mint a session id + deeplink for the user         |
| [`awaitPairResult`](#awaitpairresult)       | Long-poll until the user approves / rejects       |
| [`validateSpKey`](#validatespkey)           | Clear the appliance's pending gate after pair     |
| [`getEnabledWallets`](#getenabledwallets)   | List the addresses you can sign for               |
| [`subscribeToEvents`](#subscribetoevents)   | Watch a pair for revocations                      |
| [`revokePairing`](#revokepairing)           | Revoke from your side (e.g. user deleted account) |
| [`buildPairUri`](#buildpairuri)             | Build the deeplink URL (single-device or QR)      |
| [`mintValidationCode`](#mintvalidationcode) | Six-digit code generator                          |

Plus typed errors via `PairError`.

### Signing transport (backend-only)

| Symbol                                                      | Purpose                                              |
| ----------------------------------------------------------- | ---------------------------------------------------- |
| [`AstrotrainClient`](#astrotrainclient)                     | Stateful client for `POST /sp/sign`                  |
| [`sign`](#one-shot-sign)                                    | Free-function one-shot equivalent                    |
| [`SignerCallback`](#kms--hsm-via-signercallback)            | Plug a KMS / HSM signer in lieu of raw seed          |
| [`buildSpMessage`](#buildspmessage)                         | Construct the SP-signed envelope bytes (testability) |
| `SignError`                                                 | Discriminated error class for the signing path       |
| `hexToBytes` / `bytesToHex` / `b64uToBytes` / `bytesToB64u` | Codec helpers for payloads                           |

## `createPairSession`

```ts
const session = await createPairSession({
  apiKey: API_KEY,
  octaneUrl: OCTANE_URL,
  spUserRef: "user-42",
  requestedScopes: [
    { chain: "ethereum", access: "sign" },
    { chain: "polygon", access: "read" },
  ],
  expiresInSecs: 300,            // optional, default 300, max 600
  returnUrl: "https://you.com/paired", // optional, single-device only
  clientMeta: { platform: "web" },     // optional, opaque
});
// → { session_id, pair_uri, expires_at, return_url? }
```

Render `session.pair_uri` to the user — as a QR (desktop) or as a tap link (mobile). For mobile/single-device, prefer [`buildPairUri`](#buildpairuri) so you can embed the validation code in the URL.

## `awaitPairResult`

```ts
try {
  const pair = await awaitPairResult({
    apiKey: API_KEY,
    octaneUrl: OCTANE_URL,
    sessionId: session.session_id,
    signal: abortController.signal,  // optional, for cancellation
  });
  // pair = { pair_id, session_id, sp_user_ref, bindings, completed_at }
} catch (err) {
  if (err instanceof PairError) {
    if (err.kind === "rejected") { /* user said no */ }
    if (err.kind === "expired")  { /* TTL ran out */ }
  }
}
```

Internally uses an SSE long-poll. Cancel with `signal` if the user navigates away. The promise resolves at most once.

## `validateSpKey`

After `awaitPairResult` returns, the appliance has stored the pair in a *pending* state. To activate it (and prove to the user that you actually got the right pair\_id), you ask the appliance to sign the 6-digit validation code:

```ts
const result = await validateSpKey({
  apiKey: API_KEY,
  octaneUrl: OCTANE_URL,
  pairId: pair.pair_id,
  code: codeYouRolledAtSessionCreate,
});
// result.signature is the appliance's signature over the code
```

You typically run this once, immediately after pair completes, then the pair is fully active and signing requests work.

## `getEnabledWallets`

List the wallets currently authorised for a pair, keyed by chain:

```ts
const wallets = await getEnabledWallets({
  apiKey: API_KEY,
  octaneUrl: OCTANE_URL,
  pairId,
});
// wallets.entries = [
//   { chain: "ethereum", wallet_index: 0, address: "0x...", read: true, sign: true },
//   ...
// ]
```

Source of truth for "what can I sign with right now" — also reflects revocations and partial scope grants.

## `subscribeToEvents`

Single-frame SSE subscription that resolves when the pair's lifecycle state changes — today, only `pair_revoked`:

```ts
const event = await subscribeToEvents({
  apiKey: API_KEY,
  octaneUrl: OCTANE_URL,
  pairId,
  signal: ac.signal,
});
if (event.kind === "pair_revoked") {
  // Drop your local state, re-pair if you need this user.
}
```

## `revokePairing`

Tear down a pair from your side:

```ts
const outcome = await revokePairing({
  apiKey: API_KEY,
  octaneUrl: OCTANE_URL,
  pairId,
});
// outcome = { revoked: true|false, pair_id, revoked_at_ms }
```

Idempotent — second call returns `revoked: false` plus the unchanged row. Use for "user deleted their account" or "user rotated their SP identity."

## `buildPairUri`

Helper for single-device flows; constructs the URL the user taps:

```ts
import { buildPairUri } from "@sigil/astrotrain";

buildPairUri({ sessionId: "ps_abc", scheme: "https" });
// → "https://sigilvault.xyz/pair?session=ps_abc"

buildPairUri({
  sessionId: "ps_abc",
  scheme: "https",
  validationCode: "123456",
});
// → "https://sigilvault.xyz/pair?session=ps_abc&v=123456"

buildPairUri({ sessionId: "ps_abc", scheme: "sigil" });
// → "sigil://pair?session=ps_abc" — for QR on desktop
```

`scheme: "https"` produces a Universal Link / Android App Link; `scheme: "sigil"` produces the custom-scheme fallback. See [Single-device flow](/single-device.md) for when to choose which.

## `mintValidationCode`

Six-digit generator using WebCrypto:

```ts
import { mintValidationCode } from "@sigil/astrotrain";
const code = mintValidationCode(); // "012345"
```

Persist it server-side keyed by `session_id`; you'll feed it to `validateSpKey` after pair completes.

## Signing

> ⚠ **Backend-only.** `AstrotrainClient` holds the SP's Ed25519 seed (or a callback that signs with one). It must never run in a browser. Use the [proxy pattern](#proxy-pattern) on the frontend.

### `AstrotrainClient`

```ts
import { AstrotrainClient } from "@sigil/astrotrain";

const client = new AstrotrainClient({
  octaneUrl: "https://api.sigilvault.xyz/octane",
  apiKey: process.env.SIGIL_API_KEY!,
  privateKeySeed: process.env.SIGIL_SP_SEED!, // base64url, 32 bytes
});

console.log(await client.getPublicKey()); // base64url 32-byte SP pubkey

const resp = await client.sign({
  address: "0xabc…",                       // device address
  payload: new Uint8Array([/* tx hash */]),
  algo: "secp256k1",                       // or "ed25519"
});

// resp.device_signature  — base64url, what you submit on-chain
// resp.octane_signature  — base64url, octane's co-signature
// resp.sp_signature      — base64url, your own signature (for audit)
// resp.request_id, resp.signed_at         — server-generated metadata
```

The seed input accepts both `Uint8Array` and a base64url-no-pad string. Mirrors the Rust `AstrotrainClient::new` signature.

### KMS / HSM via `SignerCallback`

Production SPs typically don't put raw key bytes in the application process. Pass a `signer` callback instead — the SDK never sees the seed:

```ts
import { AstrotrainClient } from "@sigil/astrotrain";
import { KMS } from "@aws-sdk/client-kms";

const kms = new KMS({ region: "us-east-1" });

const client = new AstrotrainClient({
  octaneUrl: "https://api.sigilvault.xyz/octane",
  apiKey: process.env.SIGIL_API_KEY!,
  signer: {
    publicKey: process.env.SIGIL_SP_PUBKEY_B64U!, // base64url, 32 bytes
    sign: async (bytes) => {
      const out = await kms.sign({
        KeyId: process.env.KMS_KEY_ID!,
        Message: bytes,
        SigningAlgorithm: "EDDSA",
        MessageType: "RAW",
      });
      return new Uint8Array(out.Signature!);
    },
  },
});
```

The callback must return exactly 64 bytes — a raw Ed25519 signature over the message bytes the SDK passes in. A wrong-length return or a thrown error surfaces as `SignError(kind: "signer_failure")` and the HTTP request to octane is never issued.

### One-shot `sign`

For SPs that only need to sign occasionally and don't want a long-lived client:

```ts
import { sign } from "@sigil/astrotrain";

const resp = await sign({
  octaneUrl: OCTANE_URL,
  apiKey: API_KEY,
  privateKeySeed: SEED,
  address: deviceAddress,
  payload: txHashBytes,
  algo: "secp256k1",
});
```

Equivalent to `new AstrotrainClient({...}).sign({...})`. Use the class form when you sign more than once — it caches the derived public key.

### `buildSpMessage`

Pure helper exposed for symmetry with the Rust crate, useful in tests and pre-flight verification. Returns `payload || addr.utf8Bytes` — the exact bytes the SDK passes to the Ed25519 signer.

```ts
import { buildSpMessage, bytesToB64u } from "@sigil/astrotrain";
const msg = buildSpMessage(payload, deviceAddress);
```

### Proxy pattern

The frontend never holds the API key or the SP seed. Wire it like:

```ts
// server (Bun + Hono shown — any framework works)
import { Hono } from "hono";
import { AstrotrainClient, hexToBytes } from "@sigil/astrotrain";

const client = new AstrotrainClient({
  octaneUrl: process.env.OCTANE_URL!,
  apiKey: process.env.SIGIL_API_KEY!,
  privateKeySeed: process.env.SIGIL_SP_SEED!,
});

const app = new Hono();
app.post("/api/sign", async (c) => {
  const { address, payloadHex, algo } = await c.req.json();
  const resp = await client.sign({
    address,
    payload: hexToBytes(payloadHex),
    algo,
  });
  return c.json(resp);
});
```

The frontend calls `/api/sign` and never sees `SIGIL_API_KEY` or `SIGIL_SP_SEED`.

## Error model

### `PairError` (pairing surface)

`PairError` is one class, branched on `kind`:

| `kind`                | Meaning                                 | Extra fields             |
| --------------------- | --------------------------------------- | ------------------------ |
| `network`             | Transport failure (DNS, refused, abort) | `cause`                  |
| `http`                | Non-2xx from octane                     | `status`, `body`         |
| `deserialise`         | Response shape didn't match             | `cause?`                 |
| `malformed_sse_frame` | SSE frame syntactically broken          | —                        |
| `rejected`            | User rejected on appliance              | `reason`, `completed_at` |
| `expired`             | Session TTL elapsed before approval     | `completed_at`           |

The same enum names appear in [`astrotrain::pairing::PairError`](/rust/astrotrain.md) on the Rust side, so SP teams that span languages branch on identical labels.

### `SignError` (signing surface)

```ts
try {
  await client.sign({ ... });
} catch (err) {
  if (err instanceof SignError) {
    switch (err.kind) {
      case "http":          /* err.status, err.body */ break;
      case "network":       /* err.cause */ break;
      case "deserialise":   /* malformed response */ break;
      case "invalid_seed":  /* config-time, fix your input */ break;
      case "seed_length":   /* err.got — wrong byte length */ break;
      case "signer_failure":/* signer callback misbehaved */ break;
    }
  }
}
```

| `kind`           | Meaning                                                                 | Extra fields     |
| ---------------- | ----------------------------------------------------------------------- | ---------------- |
| `invalid_seed`   | Seed string isn't valid base64url, or `signer.publicKey` is wrong shape | `cause?`         |
| `seed_length`    | Decoded seed isn't 32 bytes                                             | `got`            |
| `network`        | Transport failure (DNS, refused, abort)                                 | `cause`          |
| `http`           | Non-2xx from octane                                                     | `status`, `body` |
| `deserialise`    | Response shape didn't match                                             | `cause?`         |
| `signer_failure` | `signer.sign(...)` threw or returned the wrong byte length              | `cause?`         |

`invalid_seed` / `seed_length` mirror Rust's `astrotrain::SignError` variants byte-for-byte; `signer_failure` is TypeScript-only because the Rust SDK doesn't (yet) offer a callback path.

## Forward-compat

Pair-event types are open-ended. New `kind`s on `subscribeToEvents` shouldn't break existing call sites because the discriminator pattern forces an exhaustive switch. Same goes for `SignError` — new variants will be added behind feature flags or separate releases, and existing ones will not change shape.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.sigilvault.xyz/typescript/astrotrain-ts.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
