insumer-verify 1.0.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 +21 -0
- package/README.md +139 -0
- package/build/index.d.ts +40 -0
- package/build/index.js +190 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Douglas Borthwick
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# insumer-verify
|
|
2
|
+
|
|
3
|
+
Reference verifier for [InsumerAPI](https://insumermodel.com/developers/) attestations. Validates ECDSA P-256 signatures, condition hashes, block freshness, and attestation expiry using the Web Crypto API. Zero runtime dependencies. Works in Node.js 18+ and modern browsers.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install insumer-verify
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Node.js
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
import { verifyAttestation } from "insumer-verify";
|
|
17
|
+
|
|
18
|
+
// Call InsumerAPI
|
|
19
|
+
const res = await fetch("https://insumerapi.com/v1/attest", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
"X-API-Key": "your-api-key",
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
wallet: "0x...",
|
|
27
|
+
conditions: [
|
|
28
|
+
{
|
|
29
|
+
type: "token_balance",
|
|
30
|
+
chainId: 1,
|
|
31
|
+
contractAddress: "0xA0b8...",
|
|
32
|
+
threshold: "1000000",
|
|
33
|
+
decimals: 6,
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const apiResponse = await res.json();
|
|
40
|
+
|
|
41
|
+
// Verify the attestation
|
|
42
|
+
const result = await verifyAttestation(apiResponse);
|
|
43
|
+
|
|
44
|
+
if (result.valid) {
|
|
45
|
+
console.log("Attestation verified");
|
|
46
|
+
} else {
|
|
47
|
+
console.log("Verification failed:", result.checks);
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Browser
|
|
52
|
+
|
|
53
|
+
```html
|
|
54
|
+
<script type="module">
|
|
55
|
+
import { verifyAttestation } from "https://esm.sh/insumer-verify";
|
|
56
|
+
|
|
57
|
+
const res = await fetch("https://insumerapi.com/v1/attest", {
|
|
58
|
+
method: "POST",
|
|
59
|
+
headers: {
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"X-API-Key": "your-api-key",
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify({
|
|
64
|
+
wallet: "0x...",
|
|
65
|
+
conditions: [
|
|
66
|
+
{
|
|
67
|
+
type: "token_balance",
|
|
68
|
+
chainId: 1,
|
|
69
|
+
contractAddress: "0xA0b8...",
|
|
70
|
+
threshold: "1000000",
|
|
71
|
+
decimals: 6,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const apiResponse = await res.json();
|
|
78
|
+
const result = await verifyAttestation(apiResponse);
|
|
79
|
+
console.log(result);
|
|
80
|
+
</script>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Freshness check
|
|
84
|
+
|
|
85
|
+
Pass `maxAge` (seconds) to reject attestations where block data is too old:
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
const result = await verifyAttestation(apiResponse, { maxAge: 120 });
|
|
89
|
+
// Fails if any blockTimestamp is older than 120 seconds
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Results without `blockTimestamp` (Covalent and Solana chains) are skipped, not treated as failures.
|
|
93
|
+
|
|
94
|
+
## API
|
|
95
|
+
|
|
96
|
+
### `verifyAttestation(response, options?)`
|
|
97
|
+
|
|
98
|
+
| Parameter | Type | Description |
|
|
99
|
+
|-----------|------|-------------|
|
|
100
|
+
| `response` | `unknown` | Full InsumerAPI response (must contain `data.attestation` and `data.sig`) |
|
|
101
|
+
| `options.maxAge` | `number` | Optional max age in seconds for block freshness check |
|
|
102
|
+
|
|
103
|
+
Returns `Promise<VerifyResult>`:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
interface VerifyResult {
|
|
107
|
+
valid: boolean; // true only if ALL checks pass
|
|
108
|
+
checks: {
|
|
109
|
+
signature: { passed: boolean; reason?: string };
|
|
110
|
+
conditionHashes: { passed: boolean; failures?: number[]; reason?: string };
|
|
111
|
+
freshness: { passed: boolean; reason?: string };
|
|
112
|
+
expiry: { passed: boolean; reason?: string };
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## What gets verified
|
|
118
|
+
|
|
119
|
+
| Check | What it does |
|
|
120
|
+
|-------|-------------|
|
|
121
|
+
| **Signature** | Verifies the ECDSA P-256 signature over `{id, pass, results, attestedAt}` using InsumerAPI's public key |
|
|
122
|
+
| **Condition hashes** | Recomputes SHA-256 of each `evaluatedCondition` (canonical sorted-key JSON) and compares to `conditionHash` |
|
|
123
|
+
| **Freshness** | Checks `blockTimestamp` age against caller-defined `maxAge` (optional, skipped if not set) |
|
|
124
|
+
| **Expiry** | Checks whether the attestation's 30-minute validity window has elapsed |
|
|
125
|
+
|
|
126
|
+
## Condition hash specification
|
|
127
|
+
|
|
128
|
+
Each attestation result includes an `evaluatedCondition` object and a `conditionHash`. The hash is computed as:
|
|
129
|
+
|
|
130
|
+
1. Extract all keys from `evaluatedCondition` and sort them alphabetically
|
|
131
|
+
2. Serialize with `JSON.stringify(evaluatedCondition, sortedKeys)` (the sorted keys array as the replacer produces canonical key order)
|
|
132
|
+
3. Compute SHA-256 of the UTF-8 encoded string
|
|
133
|
+
4. Format as `"0x" + hex`
|
|
134
|
+
|
|
135
|
+
This ensures the condition that was actually evaluated on-chain can be independently verified by any third party.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* insumer-verify — Reference verifier for InsumerAPI attestations.
|
|
3
|
+
*
|
|
4
|
+
* Validates ECDSA P-256 signatures, condition hashes, block freshness,
|
|
5
|
+
* and attestation expiry using the Web Crypto API. Zero dependencies.
|
|
6
|
+
* Works in Node.js 18+ and modern browsers.
|
|
7
|
+
*/
|
|
8
|
+
export interface VerifyResult {
|
|
9
|
+
valid: boolean;
|
|
10
|
+
checks: {
|
|
11
|
+
signature: CheckResult;
|
|
12
|
+
conditionHashes: CheckResult & {
|
|
13
|
+
failures?: number[];
|
|
14
|
+
};
|
|
15
|
+
freshness: CheckResult;
|
|
16
|
+
expiry: CheckResult;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface CheckResult {
|
|
20
|
+
passed: boolean;
|
|
21
|
+
reason?: string;
|
|
22
|
+
}
|
|
23
|
+
export interface VerifyOptions {
|
|
24
|
+
/** Maximum acceptable age of blockTimestamp in seconds. Results without blockTimestamp are skipped. */
|
|
25
|
+
maxAge?: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Verify an InsumerAPI attestation response.
|
|
29
|
+
*
|
|
30
|
+
* Runs 4 independent checks:
|
|
31
|
+
* 1. **Signature** — ECDSA P-256 over {id, pass, results, attestedAt}
|
|
32
|
+
* 2. **Condition hashes** — SHA-256 of canonical sorted-key JSON
|
|
33
|
+
* 3. **Freshness** — blockTimestamp age vs caller-defined maxAge (optional)
|
|
34
|
+
* 4. **Expiry** — whether the 30-minute attestation window has elapsed
|
|
35
|
+
*
|
|
36
|
+
* @param response The full API response object (must contain data.attestation and data.sig)
|
|
37
|
+
* @param options Optional configuration: maxAge (seconds) for freshness check
|
|
38
|
+
* @returns Structured result with overall validity and per-check details
|
|
39
|
+
*/
|
|
40
|
+
export declare function verifyAttestation(response: unknown, options?: VerifyOptions): Promise<VerifyResult>;
|
package/build/index.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* insumer-verify — Reference verifier for InsumerAPI attestations.
|
|
3
|
+
*
|
|
4
|
+
* Validates ECDSA P-256 signatures, condition hashes, block freshness,
|
|
5
|
+
* and attestation expiry using the Web Crypto API. Zero dependencies.
|
|
6
|
+
* Works in Node.js 18+ and modern browsers.
|
|
7
|
+
*/
|
|
8
|
+
// ── Public key ─────────────────────────────────────────────────────
|
|
9
|
+
/**
|
|
10
|
+
* InsumerAPI ECDSA P-256 public key in JWK format.
|
|
11
|
+
* Matches the private key stored in Firebase Secret Manager (ECDSA_PRIVATE_KEY).
|
|
12
|
+
* Same key embedded in InsumerScanner for QR signature verification.
|
|
13
|
+
*/
|
|
14
|
+
const PUBLIC_KEY_JWK = {
|
|
15
|
+
kty: "EC",
|
|
16
|
+
x: "JtHPhDPnv8AfP0JSlGutxbOlxreV2Chey27Z76q3V2c",
|
|
17
|
+
y: "kn34HaxVSJfn8NxwNEBjjLkcrM_GDw1lgnqyADGuc4c",
|
|
18
|
+
crv: "P-256",
|
|
19
|
+
};
|
|
20
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
21
|
+
const subtle = globalThis.crypto?.subtle;
|
|
22
|
+
function base64ToBytes(b64) {
|
|
23
|
+
const binary = atob(b64);
|
|
24
|
+
const bytes = new Uint8Array(binary.length);
|
|
25
|
+
for (let i = 0; i < binary.length; i++) {
|
|
26
|
+
bytes[i] = binary.charCodeAt(i);
|
|
27
|
+
}
|
|
28
|
+
return bytes;
|
|
29
|
+
}
|
|
30
|
+
function bytesToHex(bytes) {
|
|
31
|
+
let hex = "";
|
|
32
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
33
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
34
|
+
}
|
|
35
|
+
return hex;
|
|
36
|
+
}
|
|
37
|
+
function parseResponse(response) {
|
|
38
|
+
const obj = response;
|
|
39
|
+
const data = obj?.data;
|
|
40
|
+
if (!data || typeof data !== "object") {
|
|
41
|
+
throw new Error("Invalid response: missing data object");
|
|
42
|
+
}
|
|
43
|
+
const attestation = data.attestation;
|
|
44
|
+
const sig = data.sig;
|
|
45
|
+
if (!attestation || typeof attestation !== "object") {
|
|
46
|
+
throw new Error("Invalid response: missing data.attestation");
|
|
47
|
+
}
|
|
48
|
+
if (typeof sig !== "string" || sig.length === 0) {
|
|
49
|
+
throw new Error("Invalid response: missing data.sig");
|
|
50
|
+
}
|
|
51
|
+
if (typeof attestation.id !== "string") {
|
|
52
|
+
throw new Error("Invalid response: missing attestation.id");
|
|
53
|
+
}
|
|
54
|
+
if (typeof attestation.pass !== "boolean") {
|
|
55
|
+
throw new Error("Invalid response: missing attestation.pass");
|
|
56
|
+
}
|
|
57
|
+
if (!Array.isArray(attestation.results)) {
|
|
58
|
+
throw new Error("Invalid response: missing attestation.results");
|
|
59
|
+
}
|
|
60
|
+
if (typeof attestation.attestedAt !== "string") {
|
|
61
|
+
throw new Error("Invalid response: missing attestation.attestedAt");
|
|
62
|
+
}
|
|
63
|
+
if (typeof attestation.expiresAt !== "string") {
|
|
64
|
+
throw new Error("Invalid response: missing attestation.expiresAt");
|
|
65
|
+
}
|
|
66
|
+
return { data: { attestation, sig } };
|
|
67
|
+
}
|
|
68
|
+
// ── Verification checks ───────────────────────────────────────────
|
|
69
|
+
async function checkSignature(attestation, sig) {
|
|
70
|
+
if (!subtle) {
|
|
71
|
+
return { passed: false, reason: "Web Crypto API not available" };
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const key = await subtle.importKey("jwk", PUBLIC_KEY_JWK, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
|
|
75
|
+
// Reconstruct the exact payload that was signed server-side.
|
|
76
|
+
// The Cloud Function signs: JSON.stringify({ id, pass, results, attestedAt })
|
|
77
|
+
const payload = JSON.stringify({
|
|
78
|
+
id: attestation.id,
|
|
79
|
+
pass: attestation.pass,
|
|
80
|
+
results: attestation.results,
|
|
81
|
+
attestedAt: attestation.attestedAt,
|
|
82
|
+
});
|
|
83
|
+
const payloadBytes = new TextEncoder().encode(payload);
|
|
84
|
+
const sigBytes = base64ToBytes(sig);
|
|
85
|
+
const valid = await subtle.verify({ name: "ECDSA", hash: "SHA-256" }, key, sigBytes.buffer, payloadBytes);
|
|
86
|
+
return valid
|
|
87
|
+
? { passed: true }
|
|
88
|
+
: { passed: false, reason: "Signature does not match payload" };
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
return {
|
|
92
|
+
passed: false,
|
|
93
|
+
reason: `Signature verification error: ${e.message}`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function checkConditionHashes(results) {
|
|
98
|
+
if (!subtle) {
|
|
99
|
+
return { passed: false, reason: "Web Crypto API not available" };
|
|
100
|
+
}
|
|
101
|
+
const failures = [];
|
|
102
|
+
for (let i = 0; i < results.length; i++) {
|
|
103
|
+
const r = results[i];
|
|
104
|
+
if (!r.evaluatedCondition || !r.conditionHash)
|
|
105
|
+
continue;
|
|
106
|
+
// Canonical JSON: sorted keys, same as Cloud Function logic
|
|
107
|
+
const sortedKeys = Object.keys(r.evaluatedCondition).sort();
|
|
108
|
+
const canonical = JSON.stringify(r.evaluatedCondition, sortedKeys);
|
|
109
|
+
const hashBuffer = await subtle.digest("SHA-256", new TextEncoder().encode(canonical));
|
|
110
|
+
const computed = "0x" + bytesToHex(new Uint8Array(hashBuffer));
|
|
111
|
+
if (computed !== r.conditionHash) {
|
|
112
|
+
failures.push(i);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (failures.length > 0) {
|
|
116
|
+
return {
|
|
117
|
+
passed: false,
|
|
118
|
+
failures,
|
|
119
|
+
reason: `Condition hash mismatch at result index(es): ${failures.join(", ")}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { passed: true };
|
|
123
|
+
}
|
|
124
|
+
function checkFreshness(results, maxAge) {
|
|
125
|
+
if (maxAge === undefined) {
|
|
126
|
+
return { passed: true, reason: "Freshness check skipped (no maxAge)" };
|
|
127
|
+
}
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const maxAgeMs = maxAge * 1000;
|
|
130
|
+
for (let i = 0; i < results.length; i++) {
|
|
131
|
+
const r = results[i];
|
|
132
|
+
if (!r.blockTimestamp)
|
|
133
|
+
continue; // Covalent/Solana results lack blockTimestamp
|
|
134
|
+
const age = now - new Date(r.blockTimestamp).getTime();
|
|
135
|
+
if (age > maxAgeMs) {
|
|
136
|
+
return {
|
|
137
|
+
passed: false,
|
|
138
|
+
reason: `Result ${i} blockTimestamp is ${Math.round(age / 1000)}s old (max: ${maxAge}s)`,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { passed: true };
|
|
143
|
+
}
|
|
144
|
+
function checkExpiry(attestation) {
|
|
145
|
+
const expiresAt = new Date(attestation.expiresAt).getTime();
|
|
146
|
+
if (isNaN(expiresAt)) {
|
|
147
|
+
return { passed: false, reason: "Invalid expiresAt timestamp" };
|
|
148
|
+
}
|
|
149
|
+
if (Date.now() > expiresAt) {
|
|
150
|
+
return { passed: false, reason: "Attestation has expired" };
|
|
151
|
+
}
|
|
152
|
+
return { passed: true };
|
|
153
|
+
}
|
|
154
|
+
// ── Main export ────────────────────────────────────────────────────
|
|
155
|
+
/**
|
|
156
|
+
* Verify an InsumerAPI attestation response.
|
|
157
|
+
*
|
|
158
|
+
* Runs 4 independent checks:
|
|
159
|
+
* 1. **Signature** — ECDSA P-256 over {id, pass, results, attestedAt}
|
|
160
|
+
* 2. **Condition hashes** — SHA-256 of canonical sorted-key JSON
|
|
161
|
+
* 3. **Freshness** — blockTimestamp age vs caller-defined maxAge (optional)
|
|
162
|
+
* 4. **Expiry** — whether the 30-minute attestation window has elapsed
|
|
163
|
+
*
|
|
164
|
+
* @param response The full API response object (must contain data.attestation and data.sig)
|
|
165
|
+
* @param options Optional configuration: maxAge (seconds) for freshness check
|
|
166
|
+
* @returns Structured result with overall validity and per-check details
|
|
167
|
+
*/
|
|
168
|
+
export async function verifyAttestation(response, options) {
|
|
169
|
+
const parsed = parseResponse(response);
|
|
170
|
+
const { attestation, sig } = parsed.data;
|
|
171
|
+
const [signature, conditionHashes, freshness, expiry] = await Promise.all([
|
|
172
|
+
checkSignature(attestation, sig),
|
|
173
|
+
checkConditionHashes(attestation.results),
|
|
174
|
+
Promise.resolve(checkFreshness(attestation.results, options?.maxAge)),
|
|
175
|
+
Promise.resolve(checkExpiry(attestation)),
|
|
176
|
+
]);
|
|
177
|
+
const valid = signature.passed &&
|
|
178
|
+
conditionHashes.passed &&
|
|
179
|
+
freshness.passed &&
|
|
180
|
+
expiry.passed;
|
|
181
|
+
return {
|
|
182
|
+
valid,
|
|
183
|
+
checks: {
|
|
184
|
+
signature,
|
|
185
|
+
conditionHashes,
|
|
186
|
+
freshness,
|
|
187
|
+
expiry,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "insumer-verify",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Reference verifier for InsumerAPI attestations. Validates ECDSA signatures, condition hashes, block freshness, and expiry. Zero dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "build/index.js",
|
|
7
|
+
"types": "build/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./build/index.js",
|
|
11
|
+
"types": "./build/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"build"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"build": "tsc",
|
|
19
|
+
"prepublishOnly": "npm run build"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"insumer",
|
|
23
|
+
"attestation",
|
|
24
|
+
"ecdsa",
|
|
25
|
+
"verification",
|
|
26
|
+
"blockchain",
|
|
27
|
+
"on-chain",
|
|
28
|
+
"web-crypto"
|
|
29
|
+
],
|
|
30
|
+
"author": "Douglas Borthwick",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/douglasborthwick-crypto/insumer-verify.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://insumermodel.com/developers/",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "^5.7.0",
|
|
42
|
+
"@types/node": "^22.0.0"
|
|
43
|
+
}
|
|
44
|
+
}
|