@clanker-chain/identity-node-client 0.0.1

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,87 @@
1
+ ### identity-node-client
2
+
3
+ Node/TypeScript client for the Bun-based identity service in this repo.
4
+
5
+ ---
6
+
7
+ ## Purpose
8
+
9
+ - Manage Ed25519 keys for a bot under `~/.openclaw/keys/{bot_id}.key`.
10
+ - Talk to the identity service over HTTP:
11
+ - `init()` — **verify only**: ensures the operator and bot exist and this bot's public key is registered (no POSTs; operator must mint the bot first).
12
+ - `GET /v1/operators/{id}`, `GET /v1/bots/{id}` for lookups.
13
+ - Produce canonical Ed25519 signatures for identity-aware messages that match the `bot-comms.md` design.
14
+
15
+ This is intended to be wrapped in an OpenClaw skill/tool so agents can call `identity_init` and `identity_sign` without handling HTTP or key management directly.
16
+
17
+ ---
18
+
19
+ ## Installation
20
+
21
+ From this repo:
22
+
23
+ ```bash
24
+ cd identity-node-client
25
+ npm install
26
+ npm run build
27
+ ```
28
+
29
+ You can then import the built library from `dist/` or reference the TypeScript source in your own tooling.
30
+
31
+ ---
32
+
33
+ ## Basic usage
34
+
35
+ ```ts
36
+ import { IdentityClient, type IdentityMessageEnvelope } from "./dist/index.js";
37
+
38
+ const client = new IdentityClient({
39
+ botId: "openclaw.france.prod-1",
40
+ operatorId: "org.openclaw.pat",
41
+ identityServiceUrl: "http://localhost:8080",
42
+ });
43
+
44
+ // One-time (idempotent) init on startup: verifies operator and bot exist and this bot's key is registered.
45
+ // The operator must have minted the bot (e.g. via identity-service CLI mint-bot) with this bot's public key first.
46
+ await client.init();
47
+
48
+ // Later, when sending a message:
49
+ const envelope: IdentityMessageEnvelope = {
50
+ from: "france-bot",
51
+ from_id: "openclaw.france.prod-1",
52
+ operator_id: "org.openclaw.pat",
53
+ to: "tooter-bot",
54
+ to_id: "openclaw.tooter.prod-1",
55
+ type: "coordination",
56
+ subtype: "task-claim",
57
+ timestamp: new Date().toISOString(),
58
+ message_id: "uuid-here",
59
+ correlation_id: undefined,
60
+ body: {
61
+ action: "claim_task",
62
+ task_id: "task-123",
63
+ },
64
+ };
65
+
66
+ const { signature, signature_scheme } = await client.signMessage(envelope);
67
+ const signedEnvelope = { ...envelope, signature, signature_scheme };
68
+ ```
69
+
70
+ ---
71
+
72
+ ## Environment variables
73
+
74
+ - `IDENTITY_SERVICE_URL` — default base URL for the identity service (fallback: `http://localhost:8080`).
75
+ - The identity service uses proof-based auth (signatures); no admin token is required for init or lookups.
76
+
77
+ ---
78
+
79
+ ## Integration with OpenClaw skills (sketch)
80
+
81
+ In an OpenClaw workspace skill, you can wrap this client behind a tool like:
82
+
83
+ - `identity_init(bot_id, operator_id)` — calls `client.init()`.
84
+ - `identity_sign(envelope)` — calls `client.signMessage(envelope)` and returns the signature to the agent.
85
+
86
+ That way, the agent never needs to know about HTTP details or key storage locations; it just invokes the skill’s tools.
87
+
@@ -0,0 +1,66 @@
1
+ import type { BotRecord, IdentityMessageEnvelope } from "./types.js";
2
+ export interface IdentityClientOptions {
3
+ botId: string;
4
+ operatorId: string;
5
+ /**
6
+ * Base URL of the identity service, e.g. "http://localhost:8080".
7
+ * Defaults to process.env.IDENTITY_SERVICE_URL or http://localhost:8080.
8
+ */
9
+ identityServiceUrl?: string;
10
+ /**
11
+ * Path to the private key file. Defaults to ~/.openclaw/keys/{botId}.key
12
+ */
13
+ keyPath?: string;
14
+ /**
15
+ * Admin token for write calls if required by the service.
16
+ * Defaults to process.env.IDENTITY_ADMIN_TOKEN.
17
+ */
18
+ adminToken?: string;
19
+ }
20
+ export declare class IdentityClient {
21
+ private readonly botId;
22
+ private readonly operatorId;
23
+ private readonly baseUrl;
24
+ private readonly keyPath;
25
+ private readonly adminToken?;
26
+ constructor(options: IdentityClientOptions);
27
+ /**
28
+ * Ensure a private key exists on disk, returning the 32-byte private key.
29
+ */
30
+ private loadOrCreatePrivateKey;
31
+ /**
32
+ * Derive the base64-encoded public key from the local private key.
33
+ */
34
+ getPublicKeyBase64(): Promise<string>;
35
+ private authHeaders;
36
+ private postJson;
37
+ private getJson;
38
+ /**
39
+ * Verify that the operator and bot exist in the identity service and that
40
+ * this instance's public key is registered on the bot. Does not create
41
+ * operator or bot; they must be minted by the operator first.
42
+ * Safe to call multiple times.
43
+ */
44
+ init(): Promise<void>;
45
+ getBot(): Promise<BotRecord>;
46
+ /**
47
+ * Canonical JSON serialization used for signing, following bot-comms.md.
48
+ */
49
+ private static canonicalizeForSignature;
50
+ /**
51
+ * Sign a message envelope using the local Ed25519 private key.
52
+ * Returns base64(signature) and signature_scheme.
53
+ */
54
+ signMessage(msg: IdentityMessageEnvelope): Promise<{
55
+ signature: string;
56
+ signature_scheme: "ed25519";
57
+ }>;
58
+ /**
59
+ * Issue a short-lived JWT for MQTT broker authentication (EdDSA / Ed25519).
60
+ * Use as the MQTT CONNECT password with username = bot_id.
61
+ * @param ttlSec Token lifetime in seconds (default 300).
62
+ * @returns Compact JWT string.
63
+ */
64
+ issueMqttToken(ttlSec?: number): Promise<string>;
65
+ }
66
+ export * from "./types.js";
package/dist/index.js ADDED
@@ -0,0 +1,198 @@
1
+ import { promises as fs } from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import * as ed25519 from "@noble/ed25519";
5
+ import { SignJWT, importJWK } from "jose";
6
+ const MQTT_TOKEN_AUD = "clanker-mqtt";
7
+ function base64url(buf) {
8
+ return Buffer.from(buf)
9
+ .toString("base64")
10
+ .replace(/\+/g, "-")
11
+ .replace(/\//g, "_")
12
+ .replace(/=+$/, "");
13
+ }
14
+ export class IdentityClient {
15
+ constructor(options) {
16
+ this.botId = options.botId;
17
+ this.operatorId = options.operatorId;
18
+ this.baseUrl = options.identityServiceUrl ?? process.env.IDENTITY_SERVICE_URL ?? "http://localhost:8080";
19
+ const defaultKeyPath = path.join(os.homedir(), ".openclaw", "keys", `${this.botId}.key`);
20
+ this.keyPath = options.keyPath ?? defaultKeyPath;
21
+ this.adminToken = options.adminToken ?? process.env.IDENTITY_ADMIN_TOKEN;
22
+ }
23
+ /**
24
+ * Ensure a private key exists on disk, returning the 32-byte private key.
25
+ */
26
+ async loadOrCreatePrivateKey() {
27
+ try {
28
+ const raw = await fs.readFile(this.keyPath, "utf8");
29
+ const bytes = Buffer.from(raw.trim(), "base64");
30
+ if (bytes.length !== 32) {
31
+ throw new Error("invalid key length");
32
+ }
33
+ return new Uint8Array(bytes);
34
+ }
35
+ catch {
36
+ await fs.mkdir(path.dirname(this.keyPath), { recursive: true });
37
+ const priv = ed25519.utils.randomPrivateKey();
38
+ const b64 = Buffer.from(priv).toString("base64");
39
+ await fs.writeFile(this.keyPath, `${b64}\n`, { encoding: "utf8", mode: 0o600 });
40
+ return priv;
41
+ }
42
+ }
43
+ /**
44
+ * Derive the base64-encoded public key from the local private key.
45
+ */
46
+ async getPublicKeyBase64() {
47
+ const priv = await this.loadOrCreatePrivateKey();
48
+ const pub = await ed25519.getPublicKeyAsync(priv);
49
+ return Buffer.from(pub).toString("base64");
50
+ }
51
+ authHeaders() {
52
+ const headers = { "content-type": "application/json" };
53
+ if (this.adminToken) {
54
+ headers["authorization"] = `Bearer ${this.adminToken}`;
55
+ }
56
+ return headers;
57
+ }
58
+ async postJson(pathName, body, requireAuth = false) {
59
+ const url = new URL(pathName, this.baseUrl).toString();
60
+ const headers = { "content-type": "application/json" };
61
+ if (requireAuth && this.adminToken) {
62
+ headers["authorization"] = `Bearer ${this.adminToken}`;
63
+ }
64
+ const res = await fetch(url, {
65
+ method: "POST",
66
+ headers,
67
+ body: JSON.stringify(body),
68
+ });
69
+ const text = await res.text();
70
+ let parsed;
71
+ try {
72
+ parsed = JSON.parse(text);
73
+ }
74
+ catch {
75
+ throw new Error(`Unexpected response from identity service: ${text}`);
76
+ }
77
+ if (!res.ok) {
78
+ const err = parsed;
79
+ throw new Error(err.message || err.error || `HTTP ${res.status}`);
80
+ }
81
+ return parsed;
82
+ }
83
+ async getJson(pathName) {
84
+ const url = new URL(pathName, this.baseUrl).toString();
85
+ const res = await fetch(url, { method: "GET" });
86
+ const text = await res.text();
87
+ let parsed;
88
+ try {
89
+ parsed = JSON.parse(text);
90
+ }
91
+ catch {
92
+ throw new Error(`Unexpected response from identity service: ${text}`);
93
+ }
94
+ if (!res.ok) {
95
+ const err = parsed;
96
+ throw new Error(err.message || err.error || `HTTP ${res.status}`);
97
+ }
98
+ return parsed;
99
+ }
100
+ /**
101
+ * Verify that the operator and bot exist in the identity service and that
102
+ * this instance's public key is registered on the bot. Does not create
103
+ * operator or bot; they must be minted by the operator first.
104
+ * Safe to call multiple times.
105
+ */
106
+ async init() {
107
+ try {
108
+ await this.getJson(`/v1/operators/${encodeURIComponent(this.operatorId)}`);
109
+ }
110
+ catch {
111
+ throw new Error("Operator not registered. Operator must be minted first (genesis or mint-operator).");
112
+ }
113
+ let bot;
114
+ try {
115
+ bot = await this.getJson(`/v1/bots/${encodeURIComponent(this.botId)}`);
116
+ }
117
+ catch {
118
+ throw new Error("Bot not registered or key not found; operator must mint this bot with your public key.");
119
+ }
120
+ const publicKey = await this.getPublicKeyBase64();
121
+ const hasKey = bot.public_keys?.some((k) => k.public_key === publicKey && k.status === "active") ?? false;
122
+ if (!hasKey) {
123
+ throw new Error("Bot not registered or key not found; operator must mint this bot with your public key.");
124
+ }
125
+ }
126
+ async getBot() {
127
+ return this.getJson(`/v1/bots/${encodeURIComponent(this.botId)}`);
128
+ }
129
+ /**
130
+ * Canonical JSON serialization used for signing, following bot-comms.md.
131
+ */
132
+ static canonicalizeForSignature(msg) {
133
+ const canonicalFields = {
134
+ body: msg.body,
135
+ correlation_id: msg.correlation_id,
136
+ from: msg.from,
137
+ from_id: msg.from_id,
138
+ message_id: msg.message_id,
139
+ operator_id: msg.operator_id,
140
+ subtype: msg.subtype,
141
+ timestamp: msg.timestamp,
142
+ to: msg.to,
143
+ to_id: msg.to_id,
144
+ type: msg.type,
145
+ };
146
+ for (const key of Object.keys(canonicalFields)) {
147
+ if (canonicalFields[key] === undefined || canonicalFields[key] === null) {
148
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
149
+ delete canonicalFields[key];
150
+ }
151
+ }
152
+ const sortedKeys = Object.keys(canonicalFields).sort();
153
+ return JSON.stringify(canonicalFields, sortedKeys);
154
+ }
155
+ /**
156
+ * Sign a message envelope using the local Ed25519 private key.
157
+ * Returns base64(signature) and signature_scheme.
158
+ */
159
+ async signMessage(msg) {
160
+ const priv = await this.loadOrCreatePrivateKey();
161
+ const canonical = IdentityClient.canonicalizeForSignature(msg);
162
+ const bytes = new TextEncoder().encode(canonical);
163
+ const sig = await ed25519.signAsync(bytes, priv);
164
+ return {
165
+ signature: Buffer.from(sig).toString("base64"),
166
+ signature_scheme: "ed25519",
167
+ };
168
+ }
169
+ /**
170
+ * Issue a short-lived JWT for MQTT broker authentication (EdDSA / Ed25519).
171
+ * Use as the MQTT CONNECT password with username = bot_id.
172
+ * @param ttlSec Token lifetime in seconds (default 300).
173
+ * @returns Compact JWT string.
174
+ */
175
+ async issueMqttToken(ttlSec = 300) {
176
+ const priv = await this.loadOrCreatePrivateKey();
177
+ const pub = await ed25519.getPublicKeyAsync(priv);
178
+ const jwk = {
179
+ kty: "OKP",
180
+ crv: "Ed25519",
181
+ d: base64url(priv),
182
+ x: base64url(pub),
183
+ };
184
+ const key = await importJWK(jwk, "EdDSA");
185
+ if (!key) {
186
+ throw new Error("Failed to import key for MQTT token");
187
+ }
188
+ const exp = Math.floor(Date.now() / 1000) + ttlSec;
189
+ const jwt = await new SignJWT({})
190
+ .setProtectedHeader({ alg: "EdDSA", typ: "JWT" })
191
+ .setSubject(this.botId)
192
+ .setAudience(MQTT_TOKEN_AUD)
193
+ .setExpirationTime(exp)
194
+ .sign(key);
195
+ return jwt;
196
+ }
197
+ }
198
+ export * from "./types.js";
@@ -0,0 +1,47 @@
1
+ export type PublicKeyStatus = "active" | "revoked";
2
+ export interface PublicKeyRecord {
3
+ key_id: string;
4
+ algorithm: string;
5
+ public_key: string;
6
+ created: string;
7
+ status: PublicKeyStatus;
8
+ metadata?: Record<string, unknown>;
9
+ }
10
+ export interface OperatorRecord {
11
+ operator_id: string;
12
+ display_name?: string;
13
+ public_keys?: PublicKeyRecord[];
14
+ status: "active" | "suspended" | "retired";
15
+ created: string;
16
+ updated: string;
17
+ metadata?: Record<string, unknown>;
18
+ }
19
+ export interface BotRecord {
20
+ bot_id: string;
21
+ display_name?: string;
22
+ operator_id: string;
23
+ aliases?: string[];
24
+ public_keys?: PublicKeyRecord[];
25
+ status: "active" | "suspended" | "retired";
26
+ created: string;
27
+ updated: string;
28
+ metadata?: Record<string, unknown>;
29
+ }
30
+ export interface IdentityMessageEnvelope {
31
+ from: string;
32
+ from_id: string;
33
+ operator_id: string;
34
+ to?: string;
35
+ to_id?: string;
36
+ type: string;
37
+ subtype?: string;
38
+ channel?: string;
39
+ timestamp: string;
40
+ message_id: string;
41
+ correlation_id?: string;
42
+ privacy?: "default" | "private" | "encrypted";
43
+ encrypted?: boolean;
44
+ encryption_scheme?: string | null;
45
+ identity_token?: string | null;
46
+ body: unknown;
47
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@clanker-chain/identity-node-client",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc -p tsconfig.json"
10
+ },
11
+ "dependencies": {
12
+ "@noble/ed25519": "^2.1.0",
13
+ "jose": "^5.9.6"
14
+ },
15
+ "devDependencies": {
16
+ "typescript": "^5.7.0",
17
+ "@types/node": "^22.10.2"
18
+ }
19
+ }
20
+
package/src/index.ts ADDED
@@ -0,0 +1,252 @@
1
+ import { promises as fs } from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import * as ed25519 from "@noble/ed25519";
5
+ import { SignJWT, importJWK } from "jose";
6
+ import type { BotRecord, IdentityMessageEnvelope, OperatorRecord, PublicKeyRecord } from "./types.js";
7
+
8
+ const MQTT_TOKEN_AUD = "clanker-mqtt";
9
+
10
+ function base64url(buf: Uint8Array): string {
11
+ return Buffer.from(buf)
12
+ .toString("base64")
13
+ .replace(/\+/g, "-")
14
+ .replace(/\//g, "_")
15
+ .replace(/=+$/, "");
16
+ }
17
+
18
+ export interface IdentityClientOptions {
19
+ botId: string;
20
+ operatorId: string;
21
+ /**
22
+ * Base URL of the identity service, e.g. "http://localhost:8080".
23
+ * Defaults to process.env.IDENTITY_SERVICE_URL or http://localhost:8080.
24
+ */
25
+ identityServiceUrl?: string;
26
+ /**
27
+ * Path to the private key file. Defaults to ~/.openclaw/keys/{botId}.key
28
+ */
29
+ keyPath?: string;
30
+ /**
31
+ * Admin token for write calls if required by the service.
32
+ * Defaults to process.env.IDENTITY_ADMIN_TOKEN.
33
+ */
34
+ adminToken?: string;
35
+ }
36
+
37
+ export class IdentityClient {
38
+ private readonly botId: string;
39
+ private readonly operatorId: string;
40
+ private readonly baseUrl: string;
41
+ private readonly keyPath: string;
42
+ private readonly adminToken?: string;
43
+
44
+ constructor(options: IdentityClientOptions) {
45
+ this.botId = options.botId;
46
+ this.operatorId = options.operatorId;
47
+ this.baseUrl = options.identityServiceUrl ?? process.env.IDENTITY_SERVICE_URL ?? "http://localhost:8080";
48
+ const defaultKeyPath = path.join(os.homedir(), ".openclaw", "keys", `${this.botId}.key`);
49
+ this.keyPath = options.keyPath ?? defaultKeyPath;
50
+ this.adminToken = options.adminToken ?? process.env.IDENTITY_ADMIN_TOKEN;
51
+ }
52
+
53
+ /**
54
+ * Ensure a private key exists on disk, returning the 32-byte private key.
55
+ */
56
+ private async loadOrCreatePrivateKey(): Promise<Uint8Array> {
57
+ try {
58
+ const raw = await fs.readFile(this.keyPath, "utf8");
59
+ const bytes = Buffer.from(raw.trim(), "base64");
60
+ if (bytes.length !== 32) {
61
+ throw new Error("invalid key length");
62
+ }
63
+ return new Uint8Array(bytes);
64
+ } catch {
65
+ await fs.mkdir(path.dirname(this.keyPath), { recursive: true });
66
+ const priv = ed25519.utils.randomPrivateKey();
67
+ const b64 = Buffer.from(priv).toString("base64");
68
+ await fs.writeFile(this.keyPath, `${b64}\n`, { encoding: "utf8", mode: 0o600 });
69
+ return priv;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Derive the base64-encoded public key from the local private key.
75
+ */
76
+ async getPublicKeyBase64(): Promise<string> {
77
+ const priv = await this.loadOrCreatePrivateKey();
78
+ const pub = await ed25519.getPublicKeyAsync(priv);
79
+ return Buffer.from(pub).toString("base64");
80
+ }
81
+
82
+ private authHeaders(): HeadersInit {
83
+ const headers: HeadersInit = { "content-type": "application/json" };
84
+ if (this.adminToken) {
85
+ headers["authorization"] = `Bearer ${this.adminToken}`;
86
+ }
87
+ return headers;
88
+ }
89
+
90
+ private async postJson<T>(pathName: string, body: unknown, requireAuth = false): Promise<T> {
91
+ const url = new URL(pathName, this.baseUrl).toString();
92
+ const headers: HeadersInit = { "content-type": "application/json" };
93
+ if (requireAuth && this.adminToken) {
94
+ headers["authorization"] = `Bearer ${this.adminToken}`;
95
+ }
96
+ const res = await fetch(url, {
97
+ method: "POST",
98
+ headers,
99
+ body: JSON.stringify(body),
100
+ });
101
+ const text = await res.text();
102
+ let parsed: unknown;
103
+ try {
104
+ parsed = JSON.parse(text);
105
+ } catch {
106
+ throw new Error(`Unexpected response from identity service: ${text}`);
107
+ }
108
+ if (!res.ok) {
109
+ const err = parsed as { error?: string; message?: string };
110
+ throw new Error(err.message || err.error || `HTTP ${res.status}`);
111
+ }
112
+ return parsed as T;
113
+ }
114
+
115
+ private async getJson<T>(pathName: string): Promise<T> {
116
+ const url = new URL(pathName, this.baseUrl).toString();
117
+ const res = await fetch(url, { method: "GET" });
118
+ const text = await res.text();
119
+ let parsed: unknown;
120
+ try {
121
+ parsed = JSON.parse(text);
122
+ } catch {
123
+ throw new Error(`Unexpected response from identity service: ${text}`);
124
+ }
125
+ if (!res.ok) {
126
+ const err = parsed as { error?: string; message?: string };
127
+ throw new Error(err.message || err.error || `HTTP ${res.status}`);
128
+ }
129
+ return parsed as T;
130
+ }
131
+
132
+ /**
133
+ * Verify that the operator and bot exist in the identity service and that
134
+ * this instance's public key is registered on the bot. Does not create
135
+ * operator or bot; they must be minted by the operator first.
136
+ * Safe to call multiple times.
137
+ */
138
+ async init(): Promise<void> {
139
+ try {
140
+ await this.getJson<OperatorRecord>(
141
+ `/v1/operators/${encodeURIComponent(this.operatorId)}`,
142
+ );
143
+ } catch {
144
+ throw new Error(
145
+ "Operator not registered. Operator must be minted first (genesis or mint-operator).",
146
+ );
147
+ }
148
+
149
+ let bot: BotRecord;
150
+ try {
151
+ bot = await this.getJson<BotRecord>(
152
+ `/v1/bots/${encodeURIComponent(this.botId)}`,
153
+ );
154
+ } catch {
155
+ throw new Error(
156
+ "Bot not registered or key not found; operator must mint this bot with your public key.",
157
+ );
158
+ }
159
+
160
+ const publicKey = await this.getPublicKeyBase64();
161
+ const hasKey =
162
+ bot.public_keys?.some(
163
+ (k) => k.public_key === publicKey && k.status === "active",
164
+ ) ?? false;
165
+ if (!hasKey) {
166
+ throw new Error(
167
+ "Bot not registered or key not found; operator must mint this bot with your public key.",
168
+ );
169
+ }
170
+ }
171
+
172
+ async getBot(): Promise<BotRecord> {
173
+ return this.getJson<BotRecord>(`/v1/bots/${encodeURIComponent(this.botId)}`);
174
+ }
175
+
176
+ /**
177
+ * Canonical JSON serialization used for signing, following bot-comms.md.
178
+ */
179
+ private static canonicalizeForSignature(msg: IdentityMessageEnvelope): string {
180
+ const canonicalFields: Record<string, unknown> = {
181
+ body: msg.body,
182
+ correlation_id: msg.correlation_id,
183
+ from: msg.from,
184
+ from_id: msg.from_id,
185
+ message_id: msg.message_id,
186
+ operator_id: msg.operator_id,
187
+ subtype: msg.subtype,
188
+ timestamp: msg.timestamp,
189
+ to: msg.to,
190
+ to_id: msg.to_id,
191
+ type: msg.type,
192
+ };
193
+ for (const key of Object.keys(canonicalFields)) {
194
+ if (canonicalFields[key] === undefined || canonicalFields[key] === null) {
195
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
196
+ delete canonicalFields[key];
197
+ }
198
+ }
199
+ const sortedKeys = Object.keys(canonicalFields).sort();
200
+ return JSON.stringify(canonicalFields, sortedKeys as (keyof typeof canonicalFields)[]);
201
+ }
202
+
203
+ /**
204
+ * Sign a message envelope using the local Ed25519 private key.
205
+ * Returns base64(signature) and signature_scheme.
206
+ */
207
+ async signMessage(msg: IdentityMessageEnvelope): Promise<{
208
+ signature: string;
209
+ signature_scheme: "ed25519";
210
+ }> {
211
+ const priv = await this.loadOrCreatePrivateKey();
212
+ const canonical = IdentityClient.canonicalizeForSignature(msg);
213
+ const bytes = new TextEncoder().encode(canonical);
214
+ const sig = await ed25519.signAsync(bytes, priv);
215
+ return {
216
+ signature: Buffer.from(sig).toString("base64"),
217
+ signature_scheme: "ed25519",
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Issue a short-lived JWT for MQTT broker authentication (EdDSA / Ed25519).
223
+ * Use as the MQTT CONNECT password with username = bot_id.
224
+ * @param ttlSec Token lifetime in seconds (default 300).
225
+ * @returns Compact JWT string.
226
+ */
227
+ async issueMqttToken(ttlSec: number = 300): Promise<string> {
228
+ const priv = await this.loadOrCreatePrivateKey();
229
+ const pub = await ed25519.getPublicKeyAsync(priv);
230
+ const jwk = {
231
+ kty: "OKP" as const,
232
+ crv: "Ed25519" as const,
233
+ d: base64url(priv),
234
+ x: base64url(pub),
235
+ };
236
+ const key = await importJWK(jwk, "EdDSA");
237
+ if (!key) {
238
+ throw new Error("Failed to import key for MQTT token");
239
+ }
240
+ const exp = Math.floor(Date.now() / 1000) + ttlSec;
241
+ const jwt = await new SignJWT({})
242
+ .setProtectedHeader({ alg: "EdDSA", typ: "JWT" })
243
+ .setSubject(this.botId)
244
+ .setAudience(MQTT_TOKEN_AUD)
245
+ .setExpirationTime(exp)
246
+ .sign(key);
247
+ return jwt;
248
+ }
249
+ }
250
+
251
+ export * from "./types.js";
252
+
package/src/types.ts ADDED
@@ -0,0 +1,52 @@
1
+ export type PublicKeyStatus = "active" | "revoked";
2
+
3
+ export interface PublicKeyRecord {
4
+ key_id: string;
5
+ algorithm: string;
6
+ public_key: string;
7
+ created: string;
8
+ status: PublicKeyStatus;
9
+ metadata?: Record<string, unknown>;
10
+ }
11
+
12
+ export interface OperatorRecord {
13
+ operator_id: string;
14
+ display_name?: string;
15
+ public_keys?: PublicKeyRecord[];
16
+ status: "active" | "suspended" | "retired";
17
+ created: string;
18
+ updated: string;
19
+ metadata?: Record<string, unknown>;
20
+ }
21
+
22
+ export interface BotRecord {
23
+ bot_id: string;
24
+ display_name?: string;
25
+ operator_id: string;
26
+ aliases?: string[];
27
+ public_keys?: PublicKeyRecord[];
28
+ status: "active" | "suspended" | "retired";
29
+ created: string;
30
+ updated: string;
31
+ metadata?: Record<string, unknown>;
32
+ }
33
+
34
+ export interface IdentityMessageEnvelope {
35
+ from: string;
36
+ from_id: string;
37
+ operator_id: string;
38
+ to?: string;
39
+ to_id?: string;
40
+ type: string;
41
+ subtype?: string;
42
+ channel?: string;
43
+ timestamp: string;
44
+ message_id: string;
45
+ correlation_id?: string;
46
+ privacy?: "default" | "private" | "encrypted";
47
+ encrypted?: boolean;
48
+ encryption_scheme?: string | null;
49
+ identity_token?: string | null;
50
+ body: unknown;
51
+ }
52
+
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "node",
6
+ "declaration": true,
7
+ "outDir": "dist",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "skipLibCheck": true,
12
+ "types": ["node"]
13
+ },
14
+ "include": ["src/**/*.ts"]
15
+ }
16
+