@dopamint-fun/open-sdk 0.1.0-dev.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # `libs/dopa-open-client-ts`
2
+
3
+ Status: implemented
4
+ Ownership: Optional official TypeScript SDK for DOPA-OPEN self-custody agents:
5
+ key custody, canonical signatures, and the Participant Session loop. Never a
6
+ required gateway — server admission checks protocol conformance and
7
+ signatures, not whether this package is used. Allowed dependencies:
8
+ `@mysten/sui` and `@noble/hashes` only. Never product-private packages.
9
+
10
+ ## Surface
11
+
12
+ - `generateKeypair` / `saveKeypair` / `loadKeypair` — one Sui-format Ed25519
13
+ keypair in `.dopa-keypair` (mode 0600). Under self-custody its derived
14
+ address is the registration `owner` and the key is the generation-1 action
15
+ key (ADR-0169).
16
+ - `deriveAgentId` / `registerCanonicalPayload` / `signOwnerAuthenticator` —
17
+ self-allocation and the registration the product verifies.
18
+ - `joinSigningBytes` / `actionSigningBytes` / `resumeSigningBytes` +
19
+ `signRaw` — the Participant Session signatures.
20
+ - `playSeat` / `SessionClient` — the HTTP session loop against
21
+ `session_base_url` (join, act, ack, resume, seat-authorization).
22
+ - `recomputeSettlementDigest` / `buildConsentRequest` — settlement consent
23
+ over the disclosed terminal-state preimage.
24
+ - `channel.ts` — the npm channel-to-install mapping every teaching surface
25
+ reads (`prod` = bare install, `dev`/`canary` = dist tags, local tarball
26
+ override).
27
+ - `dopa-open` CLI — `keygen`, `address`, `register`, `play`, `consent`, `sign`.
28
+
29
+ ## Parity
30
+
31
+ Preimages and signatures are pinned byte-identical to the Rust signer by
32
+ `libs/dopa-open-client-rs/vectors/ts-signer-parity.json`, generated by
33
+ `libs/dopa-open-client-rs/tests/ts_parity_vectors.rs` and replayed by
34
+ `src/parity.test.ts` here. Regenerate with
35
+ `WRITE_TS_PARITY_VECTORS=1 cargo test -p dopa-open-client-rs --test
36
+ ts_parity_vectors`; a wire change updates both sides together.
37
+
38
+ ## Publishing
39
+
40
+ `bun run scripts/workflow/publish-open-sdk.mjs --channel <canary|dev|prod>`
41
+ (mutate-gated). `--pack-only` builds the tarball a local stack teaches via
42
+ `DOPA_OPEN_SDK_INSTALL`.
@@ -0,0 +1,24 @@
1
+ import { type AgentKeypair } from "./keypair.js";
2
+ export declare const ACCEPTANCE_NONCE_BYTES = 32;
3
+ export interface OfferAcceptance {
4
+ /** 32 bytes */
5
+ offerId: Uint8Array;
6
+ /** 1-based seat index, as the offer states it */
7
+ seat: number;
8
+ /** 32 bytes */
9
+ agentId: Uint8Array;
10
+ /** the generation the offer admitted this seat under */
11
+ keyGeneration: number;
12
+ /** 32 bytes */
13
+ agentPublicKey: Uint8Array;
14
+ accepted: boolean;
15
+ /** 32 bytes */
16
+ acceptanceNonce: Uint8Array;
17
+ }
18
+ export declare function offerAcceptanceCanonicalPayload(acceptance: OfferAcceptance): Uint8Array;
19
+ export declare function offerAcceptanceSigningBytes(acceptance: OfferAcceptance): Uint8Array;
20
+ export interface SignedOfferAcceptance extends OfferAcceptance {
21
+ /** 64 bytes */
22
+ signature: Uint8Array;
23
+ }
24
+ export declare function signOfferAcceptance(agent: AgentKeypair, acceptance: OfferAcceptance): Promise<SignedOfferAcceptance>;
@@ -0,0 +1,48 @@
1
+ /* Accepting a match offer with the agent's own key.
2
+ *
3
+ * This is what makes a playground seat possible without platform custody. The
4
+ * offer names a seat, an agent id and the key generation the product admitted;
5
+ * accepting it signs those four facts together, so an acceptance cannot be
6
+ * lifted onto another seat, another offer, or a rotated key. The authority
7
+ * verifies the signature against the registered public key, which is why the
8
+ * platform cannot produce one for an agent whose key it does not hold.
9
+ *
10
+ * Byte-identical to `OfferAcceptance::canonical_payload` in `dopa_open_api`;
11
+ * `acceptance.test.ts` pins it against a Rust-generated vector.
12
+ */
13
+ import { ByteWriter, frameSigningBytes, textBytes } from "./bytes.js";
14
+ import { signRaw } from "./keypair.js";
15
+ const OFFER_ACCEPTANCE_DOMAIN = textBytes("dopa_open::offer_acceptance::v1");
16
+ const CANONICAL_WIRE_VERSION = 1;
17
+ const OFFER_ACCEPTANCE_OPERATION = 5;
18
+ export const ACCEPTANCE_NONCE_BYTES = 32;
19
+ export function offerAcceptanceCanonicalPayload(acceptance) {
20
+ if (!Number.isInteger(acceptance.seat) || acceptance.seat < 1 || acceptance.seat > 0xff)
21
+ throw new Error("seat must be a 1-byte index");
22
+ if (!Number.isInteger(acceptance.keyGeneration) ||
23
+ acceptance.keyGeneration < 0 ||
24
+ acceptance.keyGeneration > 0xffff_ffff)
25
+ throw new Error("key generation must fit in u32");
26
+ return new ByteWriter()
27
+ .pushBytes(OFFER_ACCEPTANCE_DOMAIN)
28
+ .pushByte(0)
29
+ .pushByte(CANONICAL_WIRE_VERSION)
30
+ .pushByte(OFFER_ACCEPTANCE_OPERATION)
31
+ .pushFixed(acceptance.offerId, 32, "offer id")
32
+ .pushByte(acceptance.seat)
33
+ .pushFixed(acceptance.agentId, 32, "agent id")
34
+ .pushU32(acceptance.keyGeneration)
35
+ .pushFixed(acceptance.agentPublicKey, 32, "agent public key")
36
+ .pushByte(acceptance.accepted ? 1 : 0)
37
+ .pushFixed(acceptance.acceptanceNonce, ACCEPTANCE_NONCE_BYTES, "acceptance nonce")
38
+ .bytes();
39
+ }
40
+ export function offerAcceptanceSigningBytes(acceptance) {
41
+ return frameSigningBytes(OFFER_ACCEPTANCE_DOMAIN, offerAcceptanceCanonicalPayload(acceptance));
42
+ }
43
+ export async function signOfferAcceptance(agent, acceptance) {
44
+ return {
45
+ ...acceptance,
46
+ signature: await signRaw(agent, offerAcceptanceSigningBytes(acceptance)),
47
+ };
48
+ }
@@ -0,0 +1,52 @@
1
+ import { type AgentKeypair } from "./keypair.js";
2
+ /** The request header an agent HTTP capability travels in. */
3
+ export declare const AGENT_HTTP_CAPABILITY_HEADER = "x-dopa-open-agent-capability";
4
+ /** The widest window a signer may claim. Without a cap, freshness would reduce
5
+ * to the signer's own opinion of how long its capability should live. */
6
+ export declare const MAX_AGENT_HTTP_CAPABILITY_WINDOW_MS = 120000;
7
+ export declare const AGENT_HTTP_CAPABILITY_NONCE_BYTES = 16;
8
+ /** The request a capability authorizes, as the server will reconstruct it. */
9
+ export interface AgentHttpBinding {
10
+ method: string;
11
+ /** Origin-form target: path plus query string when one is present. */
12
+ requestTarget: string;
13
+ body: Uint8Array;
14
+ }
15
+ export interface AgentHttpCapability {
16
+ /** 32 bytes */
17
+ agentId: Uint8Array;
18
+ /** 32 bytes */
19
+ agentPublicKey: Uint8Array;
20
+ issuedAtMs: number | bigint;
21
+ expiresAtMs: number | bigint;
22
+ /** 16 bytes, single-use */
23
+ nonce: Uint8Array;
24
+ /** 64 bytes */
25
+ signature: Uint8Array;
26
+ }
27
+ /** `AgentHttpCapability::canonical_payload`. */
28
+ export declare function agentHttpCanonicalPayload(capability: Omit<AgentHttpCapability, "signature">, binding: AgentHttpBinding): Uint8Array;
29
+ /** `AgentHttpCapability::signing_preimage` - what the agent key signs. */
30
+ export declare function agentHttpSigningBytes(capability: Omit<AgentHttpCapability, "signature">, binding: AgentHttpBinding): Uint8Array;
31
+ /** `0x`-hex of `agent_id || agent_public_key || issued_at_ms ||
32
+ * expires_at_ms || nonce || signature`, big-endian. The fixed layout is its
33
+ * own length check. */
34
+ export declare function encodeAgentHttpHeader(capability: AgentHttpCapability): string;
35
+ export declare function decodeAgentHttpHeader(value: string): AgentHttpCapability;
36
+ export interface MintAgentHttpCapabilityOptions {
37
+ /** Defaults to now. */
38
+ issuedAtMs?: number | bigint;
39
+ /** Defaults to the maximum window the server admits. */
40
+ windowMs?: number;
41
+ /** Defaults to 16 fresh random bytes. Supply one only to reproduce a vector. */
42
+ nonce?: Uint8Array;
43
+ }
44
+ /** Mint and sign one capability, and return the header to send with it.
45
+ *
46
+ * The nonce is single-use: the server records it and refuses a second
47
+ * presentation, so callers must mint a fresh capability per request rather
48
+ * than caching one for the window's duration. */
49
+ export declare function mintAgentHttpCapability(agent: AgentKeypair, agentId: Uint8Array, binding: AgentHttpBinding, options?: MintAgentHttpCapabilityOptions): Promise<{
50
+ capability: AgentHttpCapability;
51
+ header: string;
52
+ }>;
@@ -0,0 +1,139 @@
1
+ /* The agent-key HTTP capability: how a self-custodied agent authorizes one
2
+ * tour or table request without ever holding a bearer credential.
3
+ *
4
+ * Byte-identical to `dopa_open_api::agent_http`, pinned by
5
+ * `libs/dopa-open-api/vectors/agent_http_capability_v1.json`. A bearer token
6
+ * is a secret presented per request; a signature is not, so each capability is
7
+ * bound to the method, the request target, a digest of the exact body bytes,
8
+ * and a single-use nonce inside a bounded window. One minted to enter the
9
+ * playground therefore cannot act at a table, and a captured request cannot be
10
+ * replayed inside its own validity window.
11
+ *
12
+ * Method, target and body are deliberately absent from the wire struct: the
13
+ * server reconstructs them from the request it actually received, so there is
14
+ * no restated copy to compare and therefore no comparison to forget.
15
+ */
16
+ import { ByteWriter, frameSigningBytes, fromHex, textBytes, toHex } from "./bytes.js";
17
+ import { blake2b256 } from "./crypto.js";
18
+ import { signRaw } from "./keypair.js";
19
+ const AGENT_HTTP_CAPABILITY_DOMAIN = textBytes("dopa_open::agent_http_capability::v1");
20
+ const CANONICAL_WIRE_VERSION = 1;
21
+ const AGENT_HTTP_CAPABILITY_OPERATION = 1;
22
+ /** The request header an agent HTTP capability travels in. */
23
+ export const AGENT_HTTP_CAPABILITY_HEADER = "x-dopa-open-agent-capability";
24
+ /** The widest window a signer may claim. Without a cap, freshness would reduce
25
+ * to the signer's own opinion of how long its capability should live. */
26
+ export const MAX_AGENT_HTTP_CAPABILITY_WINDOW_MS = 120_000;
27
+ export const AGENT_HTTP_CAPABILITY_NONCE_BYTES = 16;
28
+ const SIGNATURE_BYTES = 64;
29
+ const HEADER_BYTES = 32 + 32 + 8 + 8 + AGENT_HTTP_CAPABILITY_NONCE_BYTES + SIGNATURE_BYTES;
30
+ const asBigInt = (value) => typeof value === "bigint" ? value : BigInt(value);
31
+ /** The same refusals `AgentHttpCapability::validate` makes, so a capability
32
+ * this package mints is never one the server would reject on shape alone. */
33
+ function validateUnsigned(capability) {
34
+ if (capability.agentId.length !== 32)
35
+ throw new Error("agent id must be 32 bytes");
36
+ if (capability.agentPublicKey.length !== 32)
37
+ throw new Error("agent public key must be 32 bytes");
38
+ if (capability.nonce.length !== AGENT_HTTP_CAPABILITY_NONCE_BYTES)
39
+ throw new Error(`nonce must be ${AGENT_HTTP_CAPABILITY_NONCE_BYTES} bytes`);
40
+ if (capability.nonce.every((byte) => byte === 0))
41
+ throw new Error("nonce must not be all zero");
42
+ const issued = asBigInt(capability.issuedAtMs);
43
+ const expires = asBigInt(capability.expiresAtMs);
44
+ if (expires <= issued)
45
+ throw new Error("expires_at_ms must be after issued_at_ms");
46
+ if (expires - issued > BigInt(MAX_AGENT_HTTP_CAPABILITY_WINDOW_MS))
47
+ throw new Error(`capability window must not exceed ${MAX_AGENT_HTTP_CAPABILITY_WINDOW_MS} ms`);
48
+ }
49
+ /** `AgentHttpCapability::canonical_payload`. */
50
+ export function agentHttpCanonicalPayload(capability, binding) {
51
+ validateUnsigned(capability);
52
+ const method = textBytes(binding.method);
53
+ const target = textBytes(binding.requestTarget);
54
+ return new ByteWriter()
55
+ .pushBytes(AGENT_HTTP_CAPABILITY_DOMAIN)
56
+ .pushByte(0)
57
+ .pushByte(CANONICAL_WIRE_VERSION)
58
+ .pushByte(AGENT_HTTP_CAPABILITY_OPERATION)
59
+ .pushU32(method.length)
60
+ .pushBytes(method)
61
+ .pushU32(target.length)
62
+ .pushBytes(target)
63
+ .pushFixed(blake2b256(binding.body), 32, "body digest")
64
+ .pushFixed(capability.agentId, 32, "agent id")
65
+ .pushFixed(capability.agentPublicKey, 32, "agent public key")
66
+ .pushU64(capability.issuedAtMs)
67
+ .pushU64(capability.expiresAtMs)
68
+ .pushFixed(capability.nonce, AGENT_HTTP_CAPABILITY_NONCE_BYTES, "nonce")
69
+ .bytes();
70
+ }
71
+ /** `AgentHttpCapability::signing_preimage` - what the agent key signs. */
72
+ export function agentHttpSigningBytes(capability, binding) {
73
+ return frameSigningBytes(AGENT_HTTP_CAPABILITY_DOMAIN, agentHttpCanonicalPayload(capability, binding));
74
+ }
75
+ /** `0x`-hex of `agent_id || agent_public_key || issued_at_ms ||
76
+ * expires_at_ms || nonce || signature`, big-endian. The fixed layout is its
77
+ * own length check. */
78
+ export function encodeAgentHttpHeader(capability) {
79
+ if (capability.signature.length !== SIGNATURE_BYTES)
80
+ throw new Error(`signature must be ${SIGNATURE_BYTES} bytes`);
81
+ validateUnsigned(capability);
82
+ return `0x${toHex(new ByteWriter()
83
+ .pushFixed(capability.agentId, 32, "agent id")
84
+ .pushFixed(capability.agentPublicKey, 32, "agent public key")
85
+ .pushU64(capability.issuedAtMs)
86
+ .pushU64(capability.expiresAtMs)
87
+ .pushFixed(capability.nonce, AGENT_HTTP_CAPABILITY_NONCE_BYTES, "nonce")
88
+ .pushBytes(capability.signature)
89
+ .bytes())}`;
90
+ }
91
+ export function decodeAgentHttpHeader(value) {
92
+ if (!value.startsWith("0x") || /[A-F]/.test(value))
93
+ throw new Error("capability header must be lowercase 0x-hex");
94
+ const bytes = fromHex(value);
95
+ if (bytes.length !== HEADER_BYTES)
96
+ throw new Error(`capability header must be ${HEADER_BYTES} bytes, got ${bytes.length}`);
97
+ const readU64 = (offset) => {
98
+ let out = 0n;
99
+ for (let index = 0; index < 8; index++)
100
+ out = (out << 8n) | BigInt(bytes[offset + index]);
101
+ return out;
102
+ };
103
+ return {
104
+ agentId: bytes.slice(0, 32),
105
+ agentPublicKey: bytes.slice(32, 64),
106
+ issuedAtMs: readU64(64),
107
+ expiresAtMs: readU64(72),
108
+ nonce: bytes.slice(80, 80 + AGENT_HTTP_CAPABILITY_NONCE_BYTES),
109
+ signature: bytes.slice(80 + AGENT_HTTP_CAPABILITY_NONCE_BYTES),
110
+ };
111
+ }
112
+ /** Mint and sign one capability, and return the header to send with it.
113
+ *
114
+ * The nonce is single-use: the server records it and refuses a second
115
+ * presentation, so callers must mint a fresh capability per request rather
116
+ * than caching one for the window's duration. */
117
+ export async function mintAgentHttpCapability(agent, agentId, binding, options = {}) {
118
+ const issuedAtMs = options.issuedAtMs ?? Date.now();
119
+ const windowMs = options.windowMs ?? MAX_AGENT_HTTP_CAPABILITY_WINDOW_MS;
120
+ const unsigned = {
121
+ agentId,
122
+ agentPublicKey: agent.publicKey,
123
+ issuedAtMs,
124
+ expiresAtMs: asBigInt(issuedAtMs) + BigInt(windowMs),
125
+ nonce: options.nonce ?? randomNonce(),
126
+ };
127
+ const signature = await signRaw(agent, agentHttpSigningBytes(unsigned, binding));
128
+ const capability = { ...unsigned, signature };
129
+ return { capability, header: encodeAgentHttpHeader(capability) };
130
+ }
131
+ function randomNonce() {
132
+ const nonce = new Uint8Array(AGENT_HTTP_CAPABILITY_NONCE_BYTES);
133
+ globalThis.crypto.getRandomValues(nonce);
134
+ // An all-zero draw is refused by the server, and is astronomically unlikely
135
+ // rather than impossible; bias the last byte rather than reject-and-retry.
136
+ if (nonce.every((byte) => byte === 0))
137
+ nonce[nonce.length - 1] = 1;
138
+ return nonce;
139
+ }
@@ -0,0 +1,40 @@
1
+ export declare function toHex(bytes: Uint8Array): string;
2
+ export declare function fromHex(hex: string): Uint8Array;
3
+ export declare function toHex0x(bytes: Uint8Array): string;
4
+ /** Length-aware equality. `every()` on an empty left-hand side is vacuously
5
+ * true, which would accept a truncated hex decode as a matching key. */
6
+ export declare function equalBytes(left: Uint8Array, right: Uint8Array): boolean;
7
+ /** An append-only byte buffer with the wire's own vocabulary. */
8
+ export declare class ByteWriter {
9
+ private chunks;
10
+ pushByte(value: number): this;
11
+ pushU16(value: number): this;
12
+ pushU32(value: number): this;
13
+ pushU64(value: number | bigint): this;
14
+ pushBytes(bytes: Uint8Array): this;
15
+ /** A fixed-width field: the wire reads exactly `length` bytes, so writing
16
+ * any other count would silently shift every field after it. */
17
+ pushFixed(bytes: Uint8Array, length: number, field: string): this;
18
+ bytes(): Uint8Array;
19
+ }
20
+ /** Strict reader for one canonical frame. Trailing bytes are a decode error. */
21
+ export declare class ByteReader {
22
+ private readonly source;
23
+ private offset;
24
+ constructor(source: Uint8Array);
25
+ remaining(): number;
26
+ consumed(): number;
27
+ readByte(field: string): number;
28
+ readU16(field: string): number;
29
+ readU32(field: string): number;
30
+ readU64(field: string): bigint;
31
+ readFixed(length: number, field: string): Uint8Array;
32
+ readLengthPrefixed(lengthField: string, bytesField: string): Uint8Array;
33
+ finish(): void;
34
+ }
35
+ export declare function concatBytes(...parts: Uint8Array[]): Uint8Array;
36
+ /** The shared signing frame: length-prefixed domain, then length-prefixed
37
+ * payload. Identical between `arena_session::wire::frame_signing_bytes` and
38
+ * `dopa_open_api::encoding::signed_preimage` on purpose - one shape to pin. */
39
+ export declare function frameSigningBytes(domain: Uint8Array, payload: Uint8Array): Uint8Array;
40
+ export declare const textBytes: (value: string) => Uint8Array;
package/dist/bytes.js ADDED
@@ -0,0 +1,166 @@
1
+ /* Byte plumbing for the canonical framings.
2
+ *
3
+ * Everything the signer frames is big-endian and explicit-length, mirroring
4
+ * the Rust writers in `arena_session::wire` and `dopa_open_api::encoding`.
5
+ * These helpers exist so every framing in this package spells a number the
6
+ * same way; a preimage that drifts from the Rust client by one byte produces
7
+ * signatures the authority silently rejects, which is why the parity vectors
8
+ * in `libs/dopa-open-client-rs/vectors/` pin all of it.
9
+ */
10
+ export function toHex(bytes) {
11
+ let out = "";
12
+ for (const byte of bytes)
13
+ out += byte.toString(16).padStart(2, "0");
14
+ return out;
15
+ }
16
+ export function fromHex(hex) {
17
+ const body = hex.startsWith("0x") ? hex.slice(2) : hex;
18
+ if (body.length % 2 !== 0)
19
+ throw new Error("hex length must be even");
20
+ const out = new Uint8Array(body.length / 2);
21
+ for (let index = 0; index < out.length; index++) {
22
+ const pair = body.slice(index * 2, index * 2 + 2);
23
+ const value = Number.parseInt(pair, 16);
24
+ if (Number.isNaN(value))
25
+ throw new Error(`bad hex byte: ${pair}`);
26
+ out[index] = value;
27
+ }
28
+ return out;
29
+ }
30
+ export function toHex0x(bytes) {
31
+ return `0x${toHex(bytes)}`;
32
+ }
33
+ /** Length-aware equality. `every()` on an empty left-hand side is vacuously
34
+ * true, which would accept a truncated hex decode as a matching key. */
35
+ export function equalBytes(left, right) {
36
+ if (left.length !== right.length)
37
+ return false;
38
+ return left.every((byte, index) => byte === right[index]);
39
+ }
40
+ /** An append-only byte buffer with the wire's own vocabulary. */
41
+ export class ByteWriter {
42
+ chunks = [];
43
+ pushByte(value) {
44
+ if (!Number.isInteger(value) || value < 0 || value > 0xff)
45
+ throw new Error(`byte out of range: ${value}`);
46
+ this.chunks.push(value);
47
+ return this;
48
+ }
49
+ pushU16(value) {
50
+ if (!Number.isInteger(value) || value < 0 || value > 0xffff)
51
+ throw new Error(`u16 out of range: ${value}`);
52
+ this.chunks.push((value >> 8) & 0xff, value & 0xff);
53
+ return this;
54
+ }
55
+ pushU32(value) {
56
+ if (!Number.isInteger(value) || value < 0 || value > 0xffffffff)
57
+ throw new Error(`u32 out of range: ${value}`);
58
+ this.chunks.push((value >>> 24) & 0xff, (value >>> 16) & 0xff, (value >>> 8) & 0xff, value & 0xff);
59
+ return this;
60
+ }
61
+ pushU64(value) {
62
+ let big = typeof value === "bigint" ? value : BigInt(value);
63
+ if (big < 0n || big > 0xffffffffffffffffn)
64
+ throw new Error(`u64 out of range: ${value}`);
65
+ const bytes = new Array(8);
66
+ for (let index = 7; index >= 0; index--) {
67
+ bytes[index] = Number(big & 0xffn);
68
+ big >>= 8n;
69
+ }
70
+ this.chunks.push(...bytes);
71
+ return this;
72
+ }
73
+ pushBytes(bytes) {
74
+ for (const byte of bytes)
75
+ this.chunks.push(byte);
76
+ return this;
77
+ }
78
+ /** A fixed-width field: the wire reads exactly `length` bytes, so writing
79
+ * any other count would silently shift every field after it. */
80
+ pushFixed(bytes, length, field) {
81
+ if (bytes.length !== length)
82
+ throw new Error(`${field} must be ${length} bytes, got ${bytes.length}`);
83
+ return this.pushBytes(bytes);
84
+ }
85
+ bytes() {
86
+ return Uint8Array.from(this.chunks);
87
+ }
88
+ }
89
+ /** Strict reader for one canonical frame. Trailing bytes are a decode error. */
90
+ export class ByteReader {
91
+ source;
92
+ offset = 0;
93
+ constructor(source) {
94
+ this.source = source;
95
+ }
96
+ remaining() {
97
+ return this.source.length - this.offset;
98
+ }
99
+ consumed() {
100
+ return this.offset;
101
+ }
102
+ readByte(field) {
103
+ const value = this.source[this.offset++];
104
+ if (value === undefined)
105
+ throw new Error(`unexpected end reading ${field}`);
106
+ return value;
107
+ }
108
+ readU16(field) {
109
+ const hi = this.readByte(field);
110
+ const lo = this.readByte(field);
111
+ return (hi << 8) | lo;
112
+ }
113
+ readU32(field) {
114
+ const b0 = this.readByte(field);
115
+ const b1 = this.readByte(field);
116
+ const b2 = this.readByte(field);
117
+ const b3 = this.readByte(field);
118
+ return ((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) >>> 0;
119
+ }
120
+ readU64(field) {
121
+ const bytes = this.readFixed(8, field);
122
+ let value = 0n;
123
+ for (const byte of bytes)
124
+ value = (value << 8n) | BigInt(byte);
125
+ return value;
126
+ }
127
+ readFixed(length, field) {
128
+ if (this.offset + length > this.source.length)
129
+ throw new Error(`unexpected end reading ${field}`);
130
+ const slice = this.source.slice(this.offset, this.offset + length);
131
+ this.offset += length;
132
+ return slice;
133
+ }
134
+ readLengthPrefixed(lengthField, bytesField) {
135
+ const length = Number(this.readU64(lengthField));
136
+ if (!Number.isSafeInteger(length) || length < 0)
137
+ throw new Error(`${lengthField} overflow`);
138
+ return this.readFixed(length, bytesField);
139
+ }
140
+ finish() {
141
+ if (this.offset !== this.source.length)
142
+ throw new Error(`trailing bytes: ${this.source.length - this.offset}`);
143
+ }
144
+ }
145
+ export function concatBytes(...parts) {
146
+ const length = parts.reduce((sum, part) => sum + part.length, 0);
147
+ const out = new Uint8Array(length);
148
+ let offset = 0;
149
+ for (const part of parts) {
150
+ out.set(part, offset);
151
+ offset += part.length;
152
+ }
153
+ return out;
154
+ }
155
+ /** The shared signing frame: length-prefixed domain, then length-prefixed
156
+ * payload. Identical between `arena_session::wire::frame_signing_bytes` and
157
+ * `dopa_open_api::encoding::signed_preimage` on purpose - one shape to pin. */
158
+ export function frameSigningBytes(domain, payload) {
159
+ return new ByteWriter()
160
+ .pushU64(domain.length)
161
+ .pushBytes(domain)
162
+ .pushU64(payload.length)
163
+ .pushBytes(payload)
164
+ .bytes();
165
+ }
166
+ export const textBytes = (value) => new TextEncoder().encode(value);
@@ -0,0 +1,38 @@
1
+ export declare const PACKAGE_NAME = "@dopamint-fun/open-sdk";
2
+ export type ReleaseChannel = "canary" | "dev" | "prod";
3
+ export declare const RELEASE_CHANNELS: readonly ReleaseChannel[];
4
+ /** The npm dist-tag a channel publishes under and installs from. */
5
+ export declare function channelDistTag(channel: ReleaseChannel): string;
6
+ /** What an agent installs on a deployment of this channel.
7
+ *
8
+ * `localOverride` wins outright: it is the tarball path or URL the local
9
+ * stack exports (`DOPA_OPEN_SDK_INSTALL`), which keeps the local loop off
10
+ * the public registry entirely. */
11
+ export declare function installSpec(channel: ReleaseChannel, localOverride?: string): string;
12
+ /** The full install command the skills and the connect panel print. */
13
+ export declare function installCommand(channel: ReleaseChannel, localOverride?: string): string;
14
+ /** Read the channel a deployment declared, refusing rather than guessing.
15
+ *
16
+ * An unset value is prod - the bare install is the safe default for any
17
+ * deployment that never says otherwise - but a set-and-wrong value is a
18
+ * configuration error, not a preference, and resolving it to anything would
19
+ * have every surface teach an install line nobody chose. */
20
+ export declare function resolveChannel(raw: string | undefined): ReleaseChannel;
21
+ /** The channel a deployment teaches, given the environment it is for.
22
+ *
23
+ * `resolveChannel` treats an unset value as prod on purpose: the bare install
24
+ * is the safe default for a deployment that never says otherwise. That default
25
+ * is also how the canary stack came to serve `npm install @dopamint-fun/open-sdk`
26
+ * — an install of a package that was not on the registry — to every agent
27
+ * that read its skill: nobody had set the variable, and nothing said so.
28
+ *
29
+ * So a deployment that names a non-production environment has to name a
30
+ * channel too. Production keeps the default, because there the default is
31
+ * the answer. A build with no environment named at all (a laptop, a fork)
32
+ * keeps the old behaviour and is not this rule's concern. */
33
+ export declare function resolveDeployedChannel(channelRaw: string | undefined, deployEnvironment: string | undefined): ReleaseChannel;
34
+ /** Whether a package version belongs on a channel - what the publish script
35
+ * enforces before it will pass `--tag`. Prod takes stable semver only; dev
36
+ * and canary take only their own prerelease identifier, so a tag and a
37
+ * version can never disagree about what a build is. */
38
+ export declare function versionMatchesChannel(version: string, channel: ReleaseChannel): boolean;
@@ -0,0 +1,86 @@
1
+ /* Release channels: one package name, three npm dist-tags.
2
+ *
3
+ * canary, dev and prod are the same package at different maturities, selected
4
+ * by tag rather than by name - a name suffix would fork the install line and
5
+ * every document teaching it. Prod rides `latest` so the bare install is
6
+ * production; the other two are opt-in by tag.
7
+ *
8
+ * This module is the single owner of the channel-to-install mapping. The
9
+ * skill serving plugin, the playground connect panel, and the SDK skill all
10
+ * read it, so a deployment can never teach one channel in one place and
11
+ * another elsewhere.
12
+ */
13
+ export const PACKAGE_NAME = "@dopamint-fun/open-sdk";
14
+ export const RELEASE_CHANNELS = [
15
+ "canary",
16
+ "dev",
17
+ "prod",
18
+ ];
19
+ /** The npm dist-tag a channel publishes under and installs from. */
20
+ export function channelDistTag(channel) {
21
+ return channel === "prod" ? "latest" : channel;
22
+ }
23
+ /** What an agent installs on a deployment of this channel.
24
+ *
25
+ * `localOverride` wins outright: it is the tarball path or URL the local
26
+ * stack exports (`DOPA_OPEN_SDK_INSTALL`), which keeps the local loop off
27
+ * the public registry entirely. */
28
+ export function installSpec(channel, localOverride) {
29
+ const override = localOverride?.trim();
30
+ if (override)
31
+ return override;
32
+ return channel === "prod"
33
+ ? PACKAGE_NAME
34
+ : `${PACKAGE_NAME}@${channelDistTag(channel)}`;
35
+ }
36
+ /** The full install command the skills and the connect panel print. */
37
+ export function installCommand(channel, localOverride) {
38
+ return `npm install ${installSpec(channel, localOverride)}`;
39
+ }
40
+ /** Read the channel a deployment declared, refusing rather than guessing.
41
+ *
42
+ * An unset value is prod - the bare install is the safe default for any
43
+ * deployment that never says otherwise - but a set-and-wrong value is a
44
+ * configuration error, not a preference, and resolving it to anything would
45
+ * have every surface teach an install line nobody chose. */
46
+ export function resolveChannel(raw) {
47
+ const value = raw?.trim();
48
+ if (!value)
49
+ return "prod";
50
+ if (RELEASE_CHANNELS.includes(value))
51
+ return value;
52
+ throw new Error(`DOPA_OPEN_SDK_CHANNEL must be one of ${RELEASE_CHANNELS.join(", ")}; got "${value}"`);
53
+ }
54
+ /** The channel a deployment teaches, given the environment it is for.
55
+ *
56
+ * `resolveChannel` treats an unset value as prod on purpose: the bare install
57
+ * is the safe default for a deployment that never says otherwise. That default
58
+ * is also how the canary stack came to serve `npm install @dopamint-fun/open-sdk`
59
+ * — an install of a package that was not on the registry — to every agent
60
+ * that read its skill: nobody had set the variable, and nothing said so.
61
+ *
62
+ * So a deployment that names a non-production environment has to name a
63
+ * channel too. Production keeps the default, because there the default is
64
+ * the answer. A build with no environment named at all (a laptop, a fork)
65
+ * keeps the old behaviour and is not this rule's concern. */
66
+ export function resolveDeployedChannel(channelRaw, deployEnvironment) {
67
+ const environment = deployEnvironment?.trim();
68
+ if (environment && environment !== "production" && !channelRaw?.trim()) {
69
+ throw new Error(`DOPA_OPEN_SDK_CHANNEL is unset on the ${environment} deployment. Unset ` +
70
+ `means prod, and a ${environment} stack teaching the production install ` +
71
+ `line is how canary served an install nobody could run. Set it to one ` +
72
+ `of ${RELEASE_CHANNELS.join(", ")} on the dopamint-arena-${environment} environment.`);
73
+ }
74
+ return resolveChannel(channelRaw);
75
+ }
76
+ /** Whether a package version belongs on a channel - what the publish script
77
+ * enforces before it will pass `--tag`. Prod takes stable semver only; dev
78
+ * and canary take only their own prerelease identifier, so a tag and a
79
+ * version can never disagree about what a build is. */
80
+ export function versionMatchesChannel(version, channel) {
81
+ const stable = /^\d+\.\d+\.\d+$/;
82
+ if (channel === "prod")
83
+ return stable.test(version);
84
+ const prerelease = new RegExp(`^\\d+\\.\\d+\\.\\d+-${channel}\\.\\d+$`);
85
+ return prerelease.test(version);
86
+ }