@greenlandai/sdk 0.1.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/LICENSE +208 -0
- package/README.md +54 -0
- package/dist/index.cjs +1011 -0
- package/index.js +40 -0
- package/package.json +33 -0
- package/src/core/crypto.js +301 -0
- package/src/core/errors.js +33 -0
- package/src/core/idempotency.js +83 -0
- package/src/core/transport.js +124 -0
- package/src/enyal.js +165 -0
- package/src/gldai.js +40 -0
- package/src/joulepai.js +116 -0
- package/src/rareeai.js +66 -0
package/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @greenlandai/sdk — unified SDK for the GreenlandAI ecosystem.
|
|
3
|
+
* Design contract: unified-greenlandai-sdk-design-v1-2026-06-10.md (v1.1).
|
|
4
|
+
* Supersedes-but-does-not-replace @enyalai/sdk v2.1.x (permanent coexistence, §5).
|
|
5
|
+
*/
|
|
6
|
+
import { Transport } from "./src/core/transport.js";
|
|
7
|
+
import { EnyalSubmodule, ENYAL_BASE } from "./src/enyal.js";
|
|
8
|
+
import { JoulepaiSubmodule, JOULEPAI_BASE } from "./src/joulepai.js";
|
|
9
|
+
import { RareeaiSubmodule, RAREEAI_BASE } from "./src/rareeai.js";
|
|
10
|
+
import { GldaiSubmodule, GLDAI_BASE } from "./src/gldai.js";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
GreenlandAIError, AuthError, ScopeError, NotFoundError, ValidationError,
|
|
14
|
+
ConflictError, RateLimitError, ServerError, NetworkError,
|
|
15
|
+
} from "./src/core/errors.js";
|
|
16
|
+
export { ENFORCED, NOT_SUPPORTED, SAFE, IDEMPOTENCY_MAP } from "./src/core/idempotency.js";
|
|
17
|
+
export * as crypto from "./src/core/crypto.js";
|
|
18
|
+
|
|
19
|
+
export const VERSION = "0.1.0";
|
|
20
|
+
|
|
21
|
+
export class Client {
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {string} [opts.apiKey] - eyl_ API key (primary for SDK consumers).
|
|
25
|
+
* @param {string} [opts.oauthToken] - ENYAL-issued OAuth JWT (web sessions).
|
|
26
|
+
* @param {object} [opts.logger] - console-shaped logger (console/winston/pino).
|
|
27
|
+
* @param {function} [opts.onRetry] / [opts.onTerminalFailure] / [opts.onAuthExpired]
|
|
28
|
+
* @param {object} [opts.metrics] - {count(name,v), histogram(name,v)} sink (OFF default).
|
|
29
|
+
*/
|
|
30
|
+
constructor({ apiKey, oauthToken, logger, onRetry, onTerminalFailure, onAuthExpired,
|
|
31
|
+
metrics, fetchImpl,
|
|
32
|
+
enyalBaseUrl, joulepaiBaseUrl, rareeaiBaseUrl, gldaiBaseUrl } = {}) {
|
|
33
|
+
this._transport = new Transport({ apiKey, oauthToken, logger, onRetry,
|
|
34
|
+
onTerminalFailure, onAuthExpired, metrics, fetchImpl });
|
|
35
|
+
this.enyal = new EnyalSubmodule(this._transport, enyalBaseUrl || ENYAL_BASE);
|
|
36
|
+
this.joulepai = new JoulepaiSubmodule(this._transport, joulepaiBaseUrl || JOULEPAI_BASE);
|
|
37
|
+
this.rareeai = new RareeaiSubmodule(this._transport, rareeaiBaseUrl || RAREEAI_BASE);
|
|
38
|
+
this.gldai = new GldaiSubmodule(this._transport, gldaiBaseUrl || GLDAI_BASE);
|
|
39
|
+
}
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@greenlandai/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Unified SDK for the GreenlandAI ecosystem (ENYAL, JoulePAI, RAREEAI, GreenlandAI)",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.cjs",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./index.js",
|
|
11
|
+
"require": "./dist/index.cjs"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"src/",
|
|
17
|
+
"dist/",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@scure/bip39": "^1.3.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"esbuild": "^0.21.0"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "esbuild index.js --bundle --platform=node --format=cjs --external:@scure/bip39 --outfile=dist/index.cjs",
|
|
31
|
+
"test": "node --test test/"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side cryptography (design §4.1.2-4.1.3).
|
|
3
|
+
* GF256/Shamir/P-256/AES-GCM primitives ported VERBATIM from the published
|
|
4
|
+
* @enyalai/sdk@2.1.0 artifact (package/enyal-client.js:22-199 + :217-291 — pure
|
|
5
|
+
* functions only; API-calling siblings live on client.enyal).
|
|
6
|
+
* BIP39 = NEW surface (@scure/bip39, locked per design §9 item 5 / P-DAY.22);
|
|
7
|
+
* decryptKnowledgeNode absorbed per AUDIT.6 / design v1.1 Edit 3.
|
|
8
|
+
*/
|
|
9
|
+
// GF(256) Arithmetic — identical to enyal/shamir.py
|
|
10
|
+
// Generator = 3, irreducible polynomial = 0x11B (same as AES)
|
|
11
|
+
// ────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const GF256_EXP = new Uint8Array(512);
|
|
14
|
+
const GF256_LOG = new Uint8Array(256);
|
|
15
|
+
|
|
16
|
+
(function initGF256() {
|
|
17
|
+
let x = 1;
|
|
18
|
+
for (let i = 0; i < 255; i++) {
|
|
19
|
+
GF256_EXP[i] = x;
|
|
20
|
+
GF256_LOG[x] = i;
|
|
21
|
+
let hi = x << 1;
|
|
22
|
+
if (hi & 0x100) hi ^= 0x11B;
|
|
23
|
+
x = hi ^ x;
|
|
24
|
+
}
|
|
25
|
+
for (let i = 255; i < 512; i++) {
|
|
26
|
+
GF256_EXP[i] = GF256_EXP[i - 255];
|
|
27
|
+
}
|
|
28
|
+
})();
|
|
29
|
+
|
|
30
|
+
function gf256Mul(a, b) {
|
|
31
|
+
if (a === 0 || b === 0) return 0;
|
|
32
|
+
return GF256_EXP[(GF256_LOG[a] + GF256_LOG[b]) % 255];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function gf256Inv(a) {
|
|
36
|
+
if (a === 0) throw new Error("Zero has no inverse in GF(256)");
|
|
37
|
+
return GF256_EXP[255 - GF256_LOG[a]];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Shamir Lagrange interpolation at x=0 for two shares.
|
|
42
|
+
* Each share: Uint8Array [index_byte, data_0, ..., data_31] = 33 bytes.
|
|
43
|
+
* Returns: Uint8Array of 32-byte reconstructed secret.
|
|
44
|
+
*/
|
|
45
|
+
function shamirCombine(share1, share2) {
|
|
46
|
+
if (share1.length !== 33 || share2.length !== 33) {
|
|
47
|
+
throw new Error("Share combination failed — each share must be 33 bytes");
|
|
48
|
+
}
|
|
49
|
+
const x1 = share1[0], x2 = share2[0];
|
|
50
|
+
if (x1 === 0 || x2 === 0 || x1 === x2) {
|
|
51
|
+
throw new Error("Share combination failed — invalid share indices");
|
|
52
|
+
}
|
|
53
|
+
const d = x1 ^ x2;
|
|
54
|
+
const dInv = gf256Inv(d);
|
|
55
|
+
const secret = new Uint8Array(32);
|
|
56
|
+
for (let i = 0; i < 32; i++) {
|
|
57
|
+
const y1 = share1[1 + i], y2 = share2[1 + i];
|
|
58
|
+
const num = gf256Mul(y1, x2) ^ gf256Mul(y2, x1);
|
|
59
|
+
secret[i] = gf256Mul(num, dInv);
|
|
60
|
+
}
|
|
61
|
+
return secret;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ────────────────────────────────────────────────────────────────
|
|
65
|
+
// Encoding Helpers
|
|
66
|
+
// ────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function hexToBytes(hex) {
|
|
69
|
+
hex = hex.trim();
|
|
70
|
+
if (hex.length % 2 !== 0) throw new Error("Hex string has odd length");
|
|
71
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
72
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
73
|
+
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
|
74
|
+
}
|
|
75
|
+
return bytes;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function bytesToHex(bytes) {
|
|
79
|
+
return Array.from(bytes).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function base64ToBytes(b64) {
|
|
83
|
+
const bin = atob(b64);
|
|
84
|
+
const bytes = new Uint8Array(bin.length);
|
|
85
|
+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
|
86
|
+
return bytes;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ────────────────────────────────────────────────────────────────
|
|
90
|
+
// Crypto Helpers (Web Crypto API)
|
|
91
|
+
// ────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* HKDF-SHA256 matching enyal's bsv_memory._memory_kdf:
|
|
95
|
+
* PRK = HMAC-SHA256(salt=zeros(32), IKM=shared_secret)
|
|
96
|
+
* OKM = HMAC-SHA256(PRK, "joulepai-memory-v1" || 0x01)
|
|
97
|
+
*/
|
|
98
|
+
async function memoryKDF(sharedSecret) {
|
|
99
|
+
const salt = new Uint8Array(32);
|
|
100
|
+
const prkKey = await crypto.subtle.importKey("raw", salt, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
101
|
+
const prk = new Uint8Array(await crypto.subtle.sign("HMAC", prkKey, sharedSecret));
|
|
102
|
+
const okmKey = await crypto.subtle.importKey("raw", prk, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
103
|
+
const context = new TextEncoder().encode("joulepai-memory-v1");
|
|
104
|
+
const info = new Uint8Array(context.length + 1);
|
|
105
|
+
info.set(context);
|
|
106
|
+
info[context.length] = 0x01;
|
|
107
|
+
return new Uint8Array(await crypto.subtle.sign("HMAC", okmKey, info));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* AES-256-GCM decrypt. On auth tag mismatch (wrong key/share), throws
|
|
112
|
+
* a user-friendly error instead of raw crypto exception.
|
|
113
|
+
*/
|
|
114
|
+
async function aesGcmDecrypt(keyBytes, iv, ciphertext, tag) {
|
|
115
|
+
const aesKey = await crypto.subtle.importKey("raw", keyBytes, { name: "AES-GCM" }, false, ["decrypt"]);
|
|
116
|
+
const ctWithTag = new Uint8Array(ciphertext.length + tag.length);
|
|
117
|
+
ctWithTag.set(ciphertext);
|
|
118
|
+
ctWithTag.set(tag, ciphertext.length);
|
|
119
|
+
try {
|
|
120
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
121
|
+
{ name: "AES-GCM", iv, tagLength: 128 },
|
|
122
|
+
aesKey, ctWithTag
|
|
123
|
+
);
|
|
124
|
+
return new Uint8Array(plaintext);
|
|
125
|
+
} catch (_) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
"Share combination failed — invalid recovery phrase or share. " +
|
|
128
|
+
"Please verify your recovery phrase and try again."
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* P-256 point decompression (pure JS, no dependencies).
|
|
135
|
+
* Converts 33-byte compressed public key to 65-byte uncompressed.
|
|
136
|
+
*/
|
|
137
|
+
function decompressP256(compressed) {
|
|
138
|
+
if (compressed.length !== 33) throw new Error("Expected 33-byte compressed key");
|
|
139
|
+
const prefix = compressed[0];
|
|
140
|
+
if (prefix !== 0x02 && prefix !== 0x03) throw new Error("Invalid P-256 prefix");
|
|
141
|
+
|
|
142
|
+
const P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
|
|
143
|
+
const A = P - 3n;
|
|
144
|
+
const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
|
|
145
|
+
|
|
146
|
+
let x = 0n;
|
|
147
|
+
for (let i = 1; i < 33; i++) x = (x << 8n) | BigInt(compressed[i]);
|
|
148
|
+
|
|
149
|
+
const rhs = (modPow(x, 3n, P) + ((A * x) % P + P) % P + B) % P;
|
|
150
|
+
const y = modPow(rhs, (P + 1n) / 4n, P);
|
|
151
|
+
|
|
152
|
+
const yIsOdd = (y & 1n) === 1n;
|
|
153
|
+
const wantOdd = prefix === 0x03;
|
|
154
|
+
const finalY = yIsOdd === wantOdd ? y : P - y;
|
|
155
|
+
|
|
156
|
+
const out = new Uint8Array(65);
|
|
157
|
+
out[0] = 0x04;
|
|
158
|
+
for (let i = 31; i >= 0; i--) { out[1 + i] = Number((x >> BigInt((31 - i) * 8)) & 0xFFn); }
|
|
159
|
+
for (let i = 31; i >= 0; i--) { out[33 + i] = Number((finalY >> BigInt((31 - i) * 8)) & 0xFFn); }
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function modPow(base, exp, mod) {
|
|
164
|
+
let result = 1n;
|
|
165
|
+
base = ((base % mod) + mod) % mod;
|
|
166
|
+
while (exp > 0n) {
|
|
167
|
+
if (exp & 1n) result = (result * base) % mod;
|
|
168
|
+
exp >>= 1n;
|
|
169
|
+
base = (base * base) % mod;
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ────────────────────────────────────────────────────────────────
|
|
175
|
+
// TIER 1 — Client-side (zero trust in ENYAL)
|
|
176
|
+
// ────────────────────────────────────────────────────────────────
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 1. Request client-side disclosure materials from ENYAL.
|
|
180
|
+
* Returns encrypted chunks + ECDH-encrypted custodial share + poseidon_key_hash.
|
|
181
|
+
* No decryption on server. Customer share is NOT sent.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} apiKey - ENYAL API key (eyl_...)
|
|
184
|
+
* @param {string} baseUrl - API base URL (e.g. "https://api.enyal.ai")
|
|
185
|
+
* @param {string[]} chunkIds - chunk IDs to disclose
|
|
186
|
+
* @param {string} purpose - disclosure purpose description
|
|
187
|
+
export async function decryptCustodialShare(encryptedShare, customerPrivateKeyBytes, p256ScalarMul) {
|
|
188
|
+
const ephemPub = hexToBytes(encryptedShare.ephemeral_pubkey_hex);
|
|
189
|
+
const iv = hexToBytes(encryptedShare.iv_hex);
|
|
190
|
+
const tag = hexToBytes(encryptedShare.tag_hex);
|
|
191
|
+
const ct = base64ToBytes(encryptedShare.encrypted_share);
|
|
192
|
+
|
|
193
|
+
const sharedSecretX = await p256ScalarMul(customerPrivateKeyBytes, ephemPub);
|
|
194
|
+
const aesKey = await memoryKDF(sharedSecretX);
|
|
195
|
+
return aesGcmDecrypt(aesKey, iv, ct, tag);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 3. Combine shares and decrypt a chunk.
|
|
200
|
+
* GF(256) Lagrange interpolation → reconstruct private key → ECDH → AES-GCM decrypt.
|
|
201
|
+
* On wrong share: AES-GCM auth tag mismatch → clear error message.
|
|
202
|
+
*
|
|
203
|
+
* @param {Uint8Array} customerShare - 33-byte customer share (index + data)
|
|
204
|
+
* @param {Uint8Array} custodialShare - 33-byte custodial share (from decryptCustodialShare)
|
|
205
|
+
* @param {Object} chunk - chunk object from disclosure response (with encrypted_payload + encryption_metadata)
|
|
206
|
+
* @param {Function} p256ScalarMul - async (privKeyBytes, compressedPubKey) => Uint8Array(32)
|
|
207
|
+
* @returns {Promise<Uint8Array>} decrypted plaintext bytes
|
|
208
|
+
*/
|
|
209
|
+
export async function combineSharesAndDecrypt(customerShare, custodialShare, chunk, p256ScalarMul) {
|
|
210
|
+
const privateKey = shamirCombine(customerShare, custodialShare);
|
|
211
|
+
const ephemPub = hexToBytes(chunk.encryption_metadata.ecdh_public_key_hex);
|
|
212
|
+
const sharedSecretX = await p256ScalarMul(privateKey, ephemPub);
|
|
213
|
+
const aesKey = await memoryKDF(sharedSecretX);
|
|
214
|
+
const iv = hexToBytes(chunk.encryption_metadata.iv_hex);
|
|
215
|
+
const tag = hexToBytes(chunk.encryption_metadata.tag_hex);
|
|
216
|
+
const ct = base64ToBytes(chunk.encrypted_payload);
|
|
217
|
+
return aesGcmDecrypt(aesKey, iv, ct, tag);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 4. Verify share combination locally via WASM (78KB).
|
|
222
|
+
* Runs content integrity hash of reconstructed key in browser — zero server calls.
|
|
223
|
+
*
|
|
224
|
+
* @param {Uint8Array} customerShare - 33-byte customer share
|
|
225
|
+
* @param {Uint8Array} custodialShare - 33-byte custodial share
|
|
226
|
+
* @param {string} poseidonKeyHash - 64-char hex of expected key hash
|
|
227
|
+
* @param {string} [wasmUrl] - URL to shamir_verify.js (default: /static/shamir_verify.js)
|
|
228
|
+
* @returns {Promise<{valid: boolean, reconstructed_hash: string, expected_hash: string}>}
|
|
229
|
+
*/
|
|
230
|
+
export async function verifyShareCombination(customerShare, custodialShare, poseidonKeyHash, wasmUrl) {
|
|
231
|
+
const moduleUrl = wasmUrl || "/static/shamir_verify.js";
|
|
232
|
+
const mod = await import(moduleUrl);
|
|
233
|
+
await mod.default(moduleUrl.replace(".js", ".wasm"));
|
|
234
|
+
const result = mod.verify_share_combination(
|
|
235
|
+
bytesToHex(customerShare),
|
|
236
|
+
bytesToHex(custodialShare),
|
|
237
|
+
poseidonKeyHash
|
|
238
|
+
);
|
|
239
|
+
return JSON.parse(result);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ────────────────────────────────────────────────────────────────
|
|
243
|
+
// TIER 2 — Proof server (trust ENYAL during proof generation)
|
|
244
|
+
// ────────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* 5. Request a cryptographic share combination proof from ENYAL (Tier 2).
|
|
248
|
+
* Customer sends their share to the server. ENYAL retrieves its custodial share,
|
|
249
|
+
* generates a zero-knowledge proof via the proof server, then wipes both shares.
|
|
250
|
+
*
|
|
251
|
+
* NOTE: This is NOT zero-knowledge to ENYAL. ENYAL sees the share during
|
|
252
|
+
* proof generation. The proof is for third-party auditors.
|
|
253
|
+
* For zero-trust verification, use Tier 1 (verifyShareCombination).
|
|
254
|
+
* For zero-trust proof generation, use Tier 3 (self-hosted Rust binary).
|
|
255
|
+
*
|
|
256
|
+
* @param {string} apiKey - ENYAL API key
|
|
257
|
+
* @param {string} baseUrl - API base URL
|
|
258
|
+
* @param {string} customerShareHex - 66-char hex of customer share
|
|
259
|
+
* @param {string} [poseidonKeyHash] - optional, looked up from account if omitted
|
|
260
|
+
* @returns {Promise<Object>} proof + share_attestation
|
|
261
|
+
*/
|
|
262
|
+
|
|
263
|
+
// ── BIP39 primitive (design §4.1.2) ──
|
|
264
|
+
import { mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
|
|
265
|
+
import { wordlist } from "@scure/bip39/wordlists/english";
|
|
266
|
+
import { ValidationError } from "./errors.js";
|
|
267
|
+
|
|
268
|
+
export function deriveSharesFromMnemonic(mnemonicPhrase, passphrase = "") {
|
|
269
|
+
const norm = mnemonicPhrase.trim().split(/\s+/).join(" ");
|
|
270
|
+
if (!validateMnemonic(norm, wordlist)) {
|
|
271
|
+
throw new ValidationError(null, "Invalid BIP39 mnemonic (bad word or checksum)");
|
|
272
|
+
}
|
|
273
|
+
const seed = mnemonicToSeedSync(norm, passphrase); // Uint8Array(64), BIP39 standard
|
|
274
|
+
const customerShare = new Uint8Array(33);
|
|
275
|
+
customerShare[0] = 1; // share-index byte, 33-byte format per shamirCombine contract
|
|
276
|
+
customerShare.set(seed.slice(0, 32), 1);
|
|
277
|
+
return { seed, customerShare };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// ── Absorbed stray (AUDIT.6): knowledge-node field decryption ──
|
|
281
|
+
// Verbatim port from local joulepai-sdk-js index.js:358-385.
|
|
282
|
+
export async function decryptKnowledgeNode(node, keyBase64) {
|
|
283
|
+
const key = await crypto.subtle.importKey(
|
|
284
|
+
"raw", Uint8Array.from(atob(keyBase64), (c) => c.charCodeAt(0)),
|
|
285
|
+
{ name: "AES-GCM" }, false, ["decrypt"]);
|
|
286
|
+
node.name = await _decryptNodeField(node.name, key);
|
|
287
|
+
node.summary = await _decryptNodeField(node.summary, key);
|
|
288
|
+
return node;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function _decryptNodeField(b64, key) {
|
|
292
|
+
if (!b64) return "";
|
|
293
|
+
try {
|
|
294
|
+
const d = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
|
295
|
+
if (d.length < 13) return b64;
|
|
296
|
+
const dec = await crypto.subtle.decrypt({ name: "AES-GCM", iv: d.slice(0, 12) }, key, d.slice(12));
|
|
297
|
+
return new TextDecoder().decode(dec);
|
|
298
|
+
} catch {
|
|
299
|
+
return b64;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Typed error hierarchy (design §3.2) — same names as Python. */
|
|
2
|
+
|
|
3
|
+
export class GreenlandAIError extends Error {
|
|
4
|
+
constructor(statusCode, detail, requestId = null, retryAfter = null) {
|
|
5
|
+
super(statusCode ? `${statusCode}: ${detail}` : String(detail));
|
|
6
|
+
this.name = this.constructor.name;
|
|
7
|
+
this.statusCode = statusCode;
|
|
8
|
+
this.detail = detail;
|
|
9
|
+
this.requestId = requestId;
|
|
10
|
+
this.retryAfter = retryAfter;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export class AuthError extends GreenlandAIError {}
|
|
14
|
+
export class ScopeError extends GreenlandAIError {}
|
|
15
|
+
export class NotFoundError extends GreenlandAIError {}
|
|
16
|
+
export class ValidationError extends GreenlandAIError {}
|
|
17
|
+
export class ConflictError extends GreenlandAIError {}
|
|
18
|
+
export class RateLimitError extends GreenlandAIError {}
|
|
19
|
+
export class ServerError extends GreenlandAIError {}
|
|
20
|
+
export class NetworkError extends GreenlandAIError {
|
|
21
|
+
constructor(detail) { super(null, detail); }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function errorForStatus(statusCode, detail, requestId = null, retryAfter = null) {
|
|
25
|
+
if (statusCode === 401) return new AuthError(statusCode, detail, requestId);
|
|
26
|
+
if (statusCode === 403) return new ScopeError(statusCode, detail, requestId);
|
|
27
|
+
if (statusCode === 404) return new NotFoundError(statusCode, detail, requestId);
|
|
28
|
+
if (statusCode === 400 || statusCode === 422) return new ValidationError(statusCode, detail, requestId);
|
|
29
|
+
if (statusCode === 409) return new ConflictError(statusCode, detail, requestId, retryAfter);
|
|
30
|
+
if (statusCode === 429) return new RateLimitError(statusCode, detail, requestId, retryAfter);
|
|
31
|
+
if (statusCode >= 500) return new ServerError(statusCode, detail, requestId);
|
|
32
|
+
return new GreenlandAIError(statusCode, detail, requestId);
|
|
33
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-source idempotency dispatch (design §3.3, (ar) lock — four wire classes).
|
|
3
|
+
* Extends the published @enyalai/sdk 2.1.0 pattern (package/enyal-client.js:306-318).
|
|
4
|
+
* Tiers reflect the §6 matrix POST-G1 (build log 2026-06-10). Mirrors Python
|
|
5
|
+
* greenlandai/_core/idempotency.py exactly — single conceptual map, two renderings,
|
|
6
|
+
* cross-checked by test category (b)/(c).
|
|
7
|
+
*/
|
|
8
|
+
import { randomUUID } from "node:crypto";
|
|
9
|
+
|
|
10
|
+
export const ENFORCED = "ENFORCED";
|
|
11
|
+
export const NOT_SUPPORTED = "NOT_SUPPORTED";
|
|
12
|
+
export const SAFE = "SAFE";
|
|
13
|
+
|
|
14
|
+
export const IDEMPOTENCY_MAP = {
|
|
15
|
+
// Class A — Pattern A body-PK
|
|
16
|
+
"POST /api/v1/archive": { field: "client_chunk_id", placement: "body", tier: ENFORCED },
|
|
17
|
+
"POST /api/v1/timestamp": { field: "client_chunk_id", placement: "body", tier: ENFORCED },
|
|
18
|
+
"POST /api/v1/agreement/create": { field: "client_chunk_id", placement: "body", tier: ENFORCED },
|
|
19
|
+
"POST /api/v1/compliance/attest": { field: "client_attestation_id", placement: "body", tier: ENFORCED },
|
|
20
|
+
// Class B — Pattern B body idempotency_key
|
|
21
|
+
"POST /api/v1/prove": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
22
|
+
"POST /api/v1/prove-batch": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
23
|
+
"POST /api/v1/prove/share-combination": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
24
|
+
"POST /api/v1/disclose": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
25
|
+
"POST /api/v1/disclose/client-side": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
26
|
+
"POST /api/v1/message/send": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
27
|
+
"POST /api/v1/provide": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
28
|
+
"POST /api/v1/request": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
29
|
+
"POST /api/v1/deliver/{match_id}": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
30
|
+
"POST /api/v1/match/{match_id}/resolve": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
31
|
+
"POST /api/v1/marketplace/provide": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
32
|
+
"POST /api/v1/marketplace/request": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
33
|
+
"POST /api/v1/marketplace/deliver/{match_id}": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
34
|
+
"POST /api/v1/marketplace/match/{match_id}/resolve": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
35
|
+
// Class C — header key (metered gldai)
|
|
36
|
+
"GET /api/v1/companies": { field: "X-Idempotency-Key", placement: "header", tier: ENFORCED },
|
|
37
|
+
"GET /api/v1/deposits": { field: "X-Idempotency-Key", placement: "header", tier: ENFORCED },
|
|
38
|
+
"GET /api/v1/infrastructure": { field: "X-Idempotency-Key", placement: "header", tier: ENFORCED },
|
|
39
|
+
"GET /api/v1/query": { field: "X-Idempotency-Key", placement: "header", tier: ENFORCED },
|
|
40
|
+
"GET /api/v1/projects": { field: "X-Idempotency-Key", placement: "header", tier: ENFORCED },
|
|
41
|
+
"GET /api/v1/mapdata": { field: "X-Idempotency-Key", placement: "header", tier: ENFORCED },
|
|
42
|
+
// Class D — transfer-class
|
|
43
|
+
"POST /wallet/transfer": { field: "idempotency_key", placement: "body", tier: ENFORCED },
|
|
44
|
+
// G1 demotions / unenforced mutating endpoints — explicit NOT_SUPPORTED
|
|
45
|
+
"POST /wallet/fund": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
46
|
+
"POST /api/v1/match/{match_id}/approve": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
47
|
+
"POST /api/v1/match/{match_id}/reject": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
48
|
+
"POST /api/v1/match/{match_id}/dispute": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
49
|
+
"POST /api/v1/match/{match_id}/extend-dispute": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
50
|
+
"POST /api/v1/oracle/confirm/{match_id}": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
51
|
+
"POST /api/v1/oracle/register": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
52
|
+
"POST /api/v1/marketplace/match/{match_id}/approve": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
53
|
+
"POST /api/v1/marketplace/match/{match_id}/reject": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
54
|
+
"POST /api/v1/marketplace/match/{match_id}/dispute": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
55
|
+
"POST /api/v1/marketplace/match/{match_id}/extend-dispute": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
56
|
+
"POST /api/v1/knowledge/upgrade": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
57
|
+
"POST /api/v1/memory/structured-query": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
58
|
+
"POST /api/v1/memory/content-query": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
59
|
+
"POST /api/v1/proof/queue": { field: null, placement: null, tier: NOT_SUPPORTED },
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function lookup(method, pathTemplate) {
|
|
63
|
+
return IDEMPOTENCY_MAP[`${method.toUpperCase()} ${pathTemplate}`] ?? null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Place the key at the correct wire position. Key resolved ONCE per call
|
|
67
|
+
* invocation, stable across retries (template: enyal-client.js retry shape). */
|
|
68
|
+
export function dispatch(method, pathTemplate, body, headers, idempotencyKey) {
|
|
69
|
+
const entry = lookup(method, pathTemplate);
|
|
70
|
+
if (!entry) {
|
|
71
|
+
return { body, headers, tier: method.toUpperCase() === "GET" ? SAFE : ENFORCED, resolvedKey: null };
|
|
72
|
+
}
|
|
73
|
+
if (entry.tier === NOT_SUPPORTED || !entry.field) {
|
|
74
|
+
return { body, headers, tier: entry.tier, resolvedKey: null };
|
|
75
|
+
}
|
|
76
|
+
const resolved = idempotencyKey || randomUUID();
|
|
77
|
+
if (entry.placement === "body") {
|
|
78
|
+
body = { ...(body || {}), [entry.field]: resolved };
|
|
79
|
+
} else if (entry.placement === "header") {
|
|
80
|
+
headers = { ...headers, [entry.field]: resolved };
|
|
81
|
+
}
|
|
82
|
+
return { body, headers, tier: entry.tier, resolvedKey: resolved };
|
|
83
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared transport: retry + auth + telemetry hooks (design §2, §3.1, §3.4).
|
|
3
|
+
* Retry semantics from the published 2.1.0 template (package/enyal-client.js:322-344):
|
|
4
|
+
* 3 retries, 500ms initial, 8s max, x2 backoff, 100-300ms jitter; retryable =
|
|
5
|
+
* network/5xx/429; Retry-After honored. Layered rule: NOT_SUPPORTED tier => no auto-retry.
|
|
6
|
+
*/
|
|
7
|
+
import { NetworkError, RateLimitError, ServerError, errorForStatus } from "./errors.js";
|
|
8
|
+
import { NOT_SUPPORTED, dispatch } from "./idempotency.js";
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_RETRIES = 3;
|
|
11
|
+
const DEFAULT_INITIAL_DELAY = 500;
|
|
12
|
+
const DEFAULT_MAX_DELAY = 8000;
|
|
13
|
+
const DEFAULT_BACKOFF_FACTOR = 2;
|
|
14
|
+
const DEFAULT_JITTER_MIN = 100;
|
|
15
|
+
const DEFAULT_JITTER_MAX = 300;
|
|
16
|
+
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
18
|
+
|
|
19
|
+
function isRetryable(err) {
|
|
20
|
+
return err instanceof NetworkError || err instanceof RateLimitError || err instanceof ServerError;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const noopLogger = { debug() {}, info() {}, warn() {}, error() {} };
|
|
24
|
+
|
|
25
|
+
export class Transport {
|
|
26
|
+
constructor({ apiKey, oauthToken, logger, onRetry, onTerminalFailure,
|
|
27
|
+
onAuthExpired, metrics, fetchImpl } = {}) {
|
|
28
|
+
if (!apiKey && !oauthToken) throw new Error("apiKey or oauthToken required");
|
|
29
|
+
this._credential = apiKey || oauthToken;
|
|
30
|
+
this.logger = logger || noopLogger;
|
|
31
|
+
this.onRetry = onRetry || null;
|
|
32
|
+
this.onTerminalFailure = onTerminalFailure || null;
|
|
33
|
+
this.onAuthExpired = onAuthExpired || null;
|
|
34
|
+
this.metrics = metrics || null; // §3.4 — OFF by default
|
|
35
|
+
this._fetch = fetchImpl || fetch;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async request(baseUrl, method, pathTemplate,
|
|
39
|
+
{ pathParams = {}, body = null, params = null,
|
|
40
|
+
idempotencyKey = null, retry = true,
|
|
41
|
+
maxRetries = DEFAULT_MAX_RETRIES } = {}) {
|
|
42
|
+
// WS-2 P1 fix (audit (cn)): discriminate transport by credential type. eyl_ API keys → X-API-Key
|
|
43
|
+
// (matches the ENYAL server + published enyal-client.js:396; `Bearer eyl_...` 401s via the JWT
|
|
44
|
+
// branch); OAuth JWTs → Authorization: Bearer. Server three-method contract: docs/canonical/
|
|
45
|
+
// security-policy.md. Non-eyl_ credentials are treated as OAuth JWTs (Bearer).
|
|
46
|
+
let headers = this._credential.startsWith("eyl_")
|
|
47
|
+
? { "X-API-Key": this._credential }
|
|
48
|
+
: { Authorization: `Bearer ${this._credential}` };
|
|
49
|
+
const d = dispatch(method, pathTemplate, body, headers, idempotencyKey);
|
|
50
|
+
body = d.body; headers = d.headers;
|
|
51
|
+
if (d.tier === NOT_SUPPORTED) retry = false; // §3.1 layered rule
|
|
52
|
+
|
|
53
|
+
let path = pathTemplate;
|
|
54
|
+
for (const [k, v] of Object.entries(pathParams)) path = path.replace(`{${k}}`, encodeURIComponent(v));
|
|
55
|
+
let url = `${baseUrl.replace(/\/+$/, "")}${path}`;
|
|
56
|
+
if (params) {
|
|
57
|
+
const qs = new URLSearchParams(
|
|
58
|
+
Object.fromEntries(Object.entries(params).filter(([, v]) => v != null)));
|
|
59
|
+
url += `?${qs}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let attempt = 0;
|
|
63
|
+
const t0 = Date.now();
|
|
64
|
+
for (;;) {
|
|
65
|
+
try {
|
|
66
|
+
const result = await this._once(method, url, headers, body);
|
|
67
|
+
this._count("sdk.requests"); this._hist("sdk.request.duration", (Date.now() - t0) / 1000);
|
|
68
|
+
if (result && result.duplicate === true) {
|
|
69
|
+
this._count("sdk.duplicates");
|
|
70
|
+
this.logger.info(`duplicate response (idempotent replay) ${method} ${path}`);
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (err.statusCode === 401 && this.onAuthExpired) this.onAuthExpired(err); // §2: surface, never auto-refresh
|
|
75
|
+
if (!retry || attempt >= maxRetries || !isRetryable(err)) {
|
|
76
|
+
if (this.onTerminalFailure) this.onTerminalFailure(err, attempt + 1);
|
|
77
|
+
this.logger.warn(`terminal failure ${method} ${path} after ${attempt + 1} attempt(s)`);
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
let delay = Math.min(DEFAULT_INITIAL_DELAY * DEFAULT_BACKOFF_FACTOR ** attempt, DEFAULT_MAX_DELAY)
|
|
81
|
+
+ DEFAULT_JITTER_MIN + Math.random() * (DEFAULT_JITTER_MAX - DEFAULT_JITTER_MIN);
|
|
82
|
+
if (err.retryAfter) delay = Math.max(delay, Number(err.retryAfter) * 1000);
|
|
83
|
+
attempt += 1;
|
|
84
|
+
this._count("sdk.retries");
|
|
85
|
+
if (this.onRetry) this.onRetry(attempt, delay, err);
|
|
86
|
+
this.logger.info(`retry ${attempt}/${maxRetries} ${method} ${path} in ${Math.round(delay)}ms`);
|
|
87
|
+
await sleep(delay);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async _once(method, url, headers, body) {
|
|
93
|
+
const redacted = { ...headers };
|
|
94
|
+
if ("Authorization" in redacted) redacted.Authorization = "[REDACTED]";
|
|
95
|
+
if ("X-API-Key" in redacted) redacted["X-API-Key"] = "[REDACTED]";
|
|
96
|
+
this.logger.debug(`request ${method} ${url} headers=${JSON.stringify(redacted)}`);
|
|
97
|
+
let resp;
|
|
98
|
+
try {
|
|
99
|
+
const opts = { method, headers: { ...headers } };
|
|
100
|
+
if (body != null) {
|
|
101
|
+
opts.headers["Content-Type"] = "application/json";
|
|
102
|
+
opts.body = JSON.stringify(body);
|
|
103
|
+
}
|
|
104
|
+
resp = await this._fetch(url, opts);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
throw new NetworkError(String(err && err.message ? err.message : err));
|
|
107
|
+
}
|
|
108
|
+
const requestId = resp.headers?.get ? resp.headers.get("x-request-id") : null;
|
|
109
|
+
const ra = resp.headers?.get ? resp.headers.get("retry-after") : null;
|
|
110
|
+
const retryAfter = ra ? Number(ra) : null;
|
|
111
|
+
if (resp.status >= 400) {
|
|
112
|
+
let detail;
|
|
113
|
+
try { const data = await resp.json(); detail = data.detail ?? resp.statusText; }
|
|
114
|
+
catch { detail = resp.statusText; }
|
|
115
|
+
if (typeof detail === "object") detail = JSON.stringify(detail);
|
|
116
|
+
throw errorForStatus(resp.status, String(detail), requestId, retryAfter);
|
|
117
|
+
}
|
|
118
|
+
if (resp.status === 204) return null;
|
|
119
|
+
return resp.json();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_count(name) { if (this.metrics) this.metrics.count(name, 1); }
|
|
123
|
+
_hist(name, v) { if (this.metrics) this.metrics.histogram(name, v); }
|
|
124
|
+
}
|